updated plugin OpenID Connect Generic version 3.11.3

This commit is contained in:
2026-06-03 21:29:12 +00:00
committed by Gitium
parent 44cba94bcb
commit 766faf9ed9
151 changed files with 8558 additions and 1213 deletions

View File

@ -99,6 +99,11 @@ class OpenID_Connect_Generic_Client_Wrapper {
// Alter the requests according to settings.
add_filter( 'openid-connect-generic-alter-request', array( $client_wrapper, 'alter_request' ), 10, 2 );
// Ensure tokens are refreshed before they expire.
if ( $settings->token_refresh_enable ) {
add_action( 'init', array( $client_wrapper, 'ensure_tokens_still_fresh' ) );
}
if ( is_admin() ) {
/*
* Use the ajax url to handle processing authorization without any html output
@ -252,7 +257,15 @@ class OpenID_Connect_Generic_Client_Wrapper {
}
$user_id = wp_get_current_user()->ID;
$last_token_response = get_user_meta( $user_id, 'openid-connect-generic-last-token-response', true );
$last_token_response = get_user_option( 'openid-connect-generic-last-token-response', $user_id );
if ( false === $last_token_response ) {
$last_token_response = get_user_meta(
$user_id,
'openid-connect-generic-last-token-response',
true
);
}
if ( ! empty( $last_token_response['expires_in'] ) && ! empty( $last_token_response['time'] ) ) {
/*
@ -296,9 +309,9 @@ class OpenID_Connect_Generic_Client_Wrapper {
}
// Capture the time so that access token expiration can be calculated later.
$token_response[] = time();
$token_response['time'] = time();
update_user_meta( $user_id, 'openid-connect-generic-last-token-response', $token_response );
update_user_option( $user_id, 'openid-connect-generic-last-token-response', $token_response );
$this->save_refresh_token( $manager, $token, $token_response );
}
@ -368,7 +381,7 @@ class OpenID_Connect_Generic_Client_Wrapper {
$redirect_url = home_url();
}
$token_response = $user->get( 'openid-connect-generic-last-token-response' );
$token_response = get_user_option( 'openid-connect-generic-last-token-response', $user->ID );
if ( ! $token_response ) {
// Happens if non-openid login was used.
return $redirect_url;
@ -377,7 +390,7 @@ class OpenID_Connect_Generic_Client_Wrapper {
$redirect_url = site_url( $redirect_url );
}
$claim = $user->get( 'openid-connect-generic-last-id-token-claim' );
$claim = get_user_option( 'openid-connect-generic-last-id-token-claim', $user->ID );
if ( isset( $claim['iss'] ) && 'https://accounts.google.com' == $claim['iss'] ) {
/*
@ -407,8 +420,20 @@ class OpenID_Connect_Generic_Client_Wrapper {
$request['timeout'] = intval( $this->settings->http_request_timeout );
}
if ( $this->settings->no_sslverify ) {
// Only allow SSL bypass in local development environments.
if (
$this->settings->no_sslverify &&
defined( 'WP_DEBUG' ) && WP_DEBUG === true &&
( ! defined( 'WP_ENVIRONMENT_TYPE' ) || WP_ENVIRONMENT_TYPE === 'local' )
) {
$request['sslverify'] = false;
// Log warning every time this is used.
$this->logger->log(
'SSL verification disabled - ONLY for development. NEVER use in production!',
'ssl-bypass-warning'
);
}
return $request;
@ -427,6 +452,32 @@ class OpenID_Connect_Generic_Client_Wrapper {
$authentication_request = $client->validate_authentication_request( $_GET );
if ( is_wp_error( $authentication_request ) ) {
// Check if this is a retryable IDP error (e.g. Safari ITP causing
// Keycloak session cookies to be blocked on cross-site navigation).
$retryable_idp_errors = array(
'temporarily_unavailable',
'authentication_expired',
'login_required',
);
$error_code = $authentication_request->get_error_code();
$is_retryable = in_array( $error_code, $retryable_idp_errors, true );
$already_retried = isset( $_GET['openid-connect-generic-retry'] );
if ( $is_retryable && ! $already_retried ) {
// Log the original error before retrying.
$this->logger->log( $authentication_request, 'retry' );
$this->logger->log( "Retrying authentication due to IDP error: {$error_code}", 'retry' );
// Build a fresh authentication URL and append a retry flag
// to prevent infinite redirect loops (max 1 retry).
$auth_url = $this->get_authentication_url();
$auth_url = add_query_arg( 'openid-connect-generic-retry', '1', $auth_url );
wp_redirect( $auth_url );
exit;
}
$this->error_redirect( $authentication_request );
}
@ -559,7 +610,10 @@ class OpenID_Connect_Generic_Client_Wrapper {
// Provide backwards compatibility for customization using the deprecated cookie method.
if ( ! empty( $_COOKIE[ self::COOKIE_REDIRECT_KEY ] ) ) {
$redirect_url = esc_url_raw( wp_unslash( $_COOKIE[ self::COOKIE_REDIRECT_KEY ] ) );
$redirect_url = wp_validate_redirect(
esc_url_raw( wp_unslash( $_COOKIE[ self::COOKIE_REDIRECT_KEY ] ) ),
home_url()
);
}
// Only do redirect-user-back action hook when the plugin is configured for it.
@ -640,9 +694,9 @@ class OpenID_Connect_Generic_Client_Wrapper {
}
// Store the tokens for future reference.
update_user_meta( $user->ID, 'openid-connect-generic-last-token-response', $token_response );
update_user_meta( $user->ID, 'openid-connect-generic-last-id-token-claim', $id_token_claim );
update_user_meta( $user->ID, 'openid-connect-generic-last-user-claim', $user_claim );
update_user_option( $user->ID, 'openid-connect-generic-last-token-response', $token_response );
update_user_option( $user->ID, 'openid-connect-generic-last-id-token-claim', $id_token_claim );
update_user_option( $user->ID, 'openid-connect-generic-last-user-claim', $user_claim );
return $user_claim;
}
@ -660,9 +714,9 @@ class OpenID_Connect_Generic_Client_Wrapper {
*/
public function login_user( $user, $token_response, $id_token_claim, $user_claim, $subject_identity ): void {
// Store the tokens for future reference.
update_user_meta( $user->ID, 'openid-connect-generic-last-token-response', $token_response );
update_user_meta( $user->ID, 'openid-connect-generic-last-id-token-claim', $id_token_claim );
update_user_meta( $user->ID, 'openid-connect-generic-last-user-claim', $user_claim );
update_user_option( $user->ID, 'openid-connect-generic-last-token-response', $token_response );
update_user_option( $user->ID, 'openid-connect-generic-last-id-token-claim', $id_token_claim );
update_user_option( $user->ID, 'openid-connect-generic-last-user-claim', $user_claim );
// Allow plugins / themes to take action using current claims on existing user (e.g. update role).
do_action( 'openid-connect-generic-update-user-using-current-claim', $user, $user_claim );
@ -713,14 +767,21 @@ class OpenID_Connect_Generic_Client_Wrapper {
* @return false|WP_User
*/
public function get_user_by_identity( $subject_identity ) {
global $wpdb;
// Look for user by their openid-connect-generic-subject-identity value.
$user_query = new WP_User_Query(
array(
'meta_query' => array(
'relation' => 'OR',
array(
'key' => 'openid-connect-generic-subject-identity',
'value' => $subject_identity,
),
array(
'key' => $wpdb->get_blog_prefix() . 'openid-connect-generic-subject-identity',
'value' => $subject_identity,
),
),
// Override the default blog_id (get_current_blog_id) to find users on different sites of a multisite install.
'blog_id' => 0,
@ -847,13 +908,48 @@ class OpenID_Connect_Generic_Client_Wrapper {
return false;
}
/**
* Extract claim from JWT.
* FIXME: We probably want to verify the JWT signature/issuer here.
* For example, using JWKS if applicable. For symmetrically signed
* JWTs (HMAC), we need a way to specify the acceptable secrets
* and each possible issuer in the config.
* Extract claim from JWT with signature verification.
*/
$jwt = $src['JWT'];
// Check if JWKS endpoint is configured for JWT signature verification.
if ( ! empty( $this->settings->endpoint_jwks ) ) {
// Use configured issuer if provided, otherwise derive from endpoint_login.
$issuer = ! empty( $this->settings->issuer ) ?
$this->settings->issuer :
( ! empty( $this->settings->endpoint_login ) ? $this->client->get_issuer_from_endpoint( $this->settings->endpoint_login ) : '' );
// Use JWT validator for secure signature verification.
$jwt_validator = new OpenID_Connect_Generic_JWT_Validator(
$this->settings->endpoint_jwks,
$this->settings->client_id,
$issuer,
$this->settings->jwks_cache_ttl,
$this->settings->allow_internal_idp,
$this->logger
);
// Validate JWT signature and claims.
$body_json = $jwt_validator->validate_id_token( $jwt );
if ( is_wp_error( $body_json ) ) {
$this->logger->log( $body_json, 'aggregated-claim-jwt-validation-failed' );
return false;
}
if ( ! array_key_exists( $claimname, $body_json ) ) {
return false;
}
$claimvalue = $body_json[ $claimname ];
return true;
}
$this->logger->log(
'SECURITY WARNING: JWKS endpoint not configured. Aggregated claim JWT signatures are NOT being verified. This is a security vulnerability. Configure the JWKS endpoint to secure aggregated claims.',
'aggregated-jwt-not-verified'
);
// Legacy JWT decoding without signature verification (INSECURE).
list ( $header, $body, $rest ) = explode( '.', $jwt, 3 );
$body_str = base64_decode( $body, false );
if ( ! $body_str ) {
@ -1098,7 +1194,7 @@ class OpenID_Connect_Generic_Client_Wrapper {
$user = get_user_by( 'id', $uid );
// Save some meta data about this new user for the future.
add_user_meta( $user->ID, 'openid-connect-generic-subject-identity', (string) $subject_identity, true );
update_user_option( $user->ID, 'openid-connect-generic-subject-identity', (string) $subject_identity, true );
// Log the results.
$end_time = microtime( true );
@ -1120,7 +1216,7 @@ class OpenID_Connect_Generic_Client_Wrapper {
*/
public function update_existing_user( $uid, $subject_identity ) {
// Add the OpenID Connect meta data.
update_user_meta( $uid, 'openid-connect-generic-subject-identity', strval( $subject_identity ) );
update_user_option( $uid, 'openid-connect-generic-subject-identity', strval( $subject_identity ), true );
// Allow plugins / themes to take action on user update.
do_action( 'openid-connect-generic-user-update', $uid );

View File

@ -91,6 +91,33 @@ class OpenID_Connect_Generic_Client {
*/
private $acr_values;
/**
* The JWKS endpoint URL for JWT signature verification.
*
* @see OpenID_Connect_Generic_Option_Settings::endpoint_jwks
*
* @var string
*/
private $endpoint_jwks;
/**
* The issuer URL for JWT validation.
*
* @see OpenID_Connect_Generic_Option_Settings::issuer
*
* @var string
*/
private $issuer;
/**
* The JWKS cache TTL in seconds.
*
* @see OpenID_Connect_Generic_Option_Settings::jwks_cache_ttl
*
* @var int
*/
private $jwks_cache_ttl;
/**
* The state time limit. States are only valid for 3 minutes.
*
@ -100,6 +127,15 @@ class OpenID_Connect_Generic_Client {
*/
private $state_time_limit = 180;
/**
* Allow HTTP requests to internal/private network endpoints.
*
* @see OpenID_Connect_Generic_Option_Settings::allow_internal_idp
*
* @var bool
*/
private $allow_internal_idp;
/**
* The logger object instance.
*
@ -110,18 +146,22 @@ class OpenID_Connect_Generic_Client {
/**
* Client constructor.
*
* @param string $client_id @see OpenID_Connect_Generic_Option_Settings::client_id for description.
* @param string $client_secret @see OpenID_Connect_Generic_Option_Settings::client_secret for description.
* @param string $scope @see OpenID_Connect_Generic_Option_Settings::scope for description.
* @param string $endpoint_login @see OpenID_Connect_Generic_Option_Settings::endpoint_login for description.
* @param string $endpoint_userinfo @see OpenID_Connect_Generic_Option_Settings::endpoint_userinfo for description.
* @param string $endpoint_token @see OpenID_Connect_Generic_Option_Settings::endpoint_token for description.
* @param string $redirect_uri @see OpenID_Connect_Generic_Option_Settings::redirect_uri for description.
* @param string $acr_values @see OpenID_Connect_Generic_Option_Settings::acr_values for description.
* @param int $state_time_limit @see OpenID_Connect_Generic_Option_Settings::state_time_limit for description.
* @param OpenID_Connect_Generic_Option_Logger $logger The plugin logging object instance.
* @param string $client_id @see OpenID_Connect_Generic_Option_Settings::client_id for description.
* @param string $client_secret @see OpenID_Connect_Generic_Option_Settings::client_secret for description.
* @param string $scope @see OpenID_Connect_Generic_Option_Settings::scope for description.
* @param string $endpoint_login @see OpenID_Connect_Generic_Option_Settings::endpoint_login for description.
* @param string $endpoint_userinfo @see OpenID_Connect_Generic_Option_Settings::endpoint_userinfo for description.
* @param string $endpoint_token @see OpenID_Connect_Generic_Option_Settings::endpoint_token for description.
* @param string $redirect_uri @see OpenID_Connect_Generic_Option_Settings::redirect_uri for description.
* @param string $acr_values @see OpenID_Connect_Generic_Option_Settings::acr_values for description.
* @param string $endpoint_jwks @see OpenID_Connect_Generic_Option_Settings::endpoint_jwks for description.
* @param string $issuer @see OpenID_Connect_Generic_Option_Settings::issuer for description.
* @param int $jwks_cache_ttl @see OpenID_Connect_Generic_Option_Settings::jwks_cache_ttl for description.
* @param int $state_time_limit @see OpenID_Connect_Generic_Option_Settings::state_time_limit for description.
* @param bool $allow_internal_idp @see OpenID_Connect_Generic_Option_Settings::allow_internal_idp for description.
* @param OpenID_Connect_Generic_Option_Logger $logger The plugin logging object instance.
*/
public function __construct( $client_id, $client_secret, $scope, $endpoint_login, $endpoint_userinfo, $endpoint_token, $redirect_uri, $acr_values, $state_time_limit, $logger ) {
public function __construct( $client_id, $client_secret, $scope, $endpoint_login, $endpoint_userinfo, $endpoint_token, $redirect_uri, $acr_values, $endpoint_jwks, $issuer, $jwks_cache_ttl, $state_time_limit, $allow_internal_idp, $logger ) {
$this->client_id = $client_id;
$this->client_secret = $client_secret;
$this->scope = $scope;
@ -130,10 +170,52 @@ class OpenID_Connect_Generic_Client {
$this->endpoint_token = $endpoint_token;
$this->redirect_uri = $redirect_uri;
$this->acr_values = $acr_values;
$this->endpoint_jwks = $endpoint_jwks;
$this->issuer = $issuer;
$this->jwks_cache_ttl = $jwks_cache_ttl;
$this->state_time_limit = $state_time_limit;
$this->allow_internal_idp = $allow_internal_idp;
$this->logger = $logger;
}
/**
* Make a safe HTTP GET request with optional internal endpoint support.
*
* By default, uses wp_safe_remote_get() which blocks requests to internal/private
* networks (SSRF protection). If allow_internal_idp is enabled, uses wp_remote_get()
* to allow connections to localhost and private network identity providers.
*
* @param string $url The URL to request.
* @param array $args Optional. Request arguments.
*
* @return array|WP_Error Response array or WP_Error on failure.
*/
private function http_get( $url, $args = array() ) {
if ( $this->allow_internal_idp ) {
return wp_remote_get( $url, $args );
}
return wp_safe_remote_get( $url, $args );
}
/**
* Make a safe HTTP POST request with optional internal endpoint support.
*
* By default, uses wp_safe_remote_post() which blocks requests to internal/private
* networks (SSRF protection). If allow_internal_idp is enabled, uses wp_remote_post()
* to allow connections to localhost and private network identity providers.
*
* @param string $url The URL to request.
* @param array $args Optional. Request arguments.
*
* @return array|WP_Error Response array or WP_Error on failure.
*/
private function http_post( $url, $args = array() ) {
if ( $this->allow_internal_idp ) {
return wp_remote_post( $url, $args );
}
return wp_safe_remote_post( $url, $args );
}
/**
* Provides the configured Redirect URI supplied to the IDP.
*
@ -162,7 +244,19 @@ class OpenID_Connect_Generic_Client {
public function validate_authentication_request( $request ) {
// Look for an existing error of some kind.
if ( isset( $request['error'] ) ) {
return new WP_Error( 'unknown-error', 'An unknown error occurred.', $request );
$error_code = sanitize_text_field( $request['error'] );
$error_message = 'An unknown error occurred.';
// Use the IDP's error description if available for better diagnostics.
if ( ! empty( $request['error_description'] ) ) {
$error_message = sprintf(
'IDP error %s: %s',
$error_code,
sanitize_text_field( $request['error_description'] )
);
}
return new WP_Error( $error_code, $error_message, $request );
}
// Make sure we have a legitimate authentication code and valid state.
@ -232,7 +326,7 @@ class OpenID_Connect_Generic_Client {
// Call the server and ask for a token.
$start_time = microtime( true );
$response = wp_remote_post( $this->endpoint_token, $request );
$response = $this->http_post( $this->endpoint_token, $request );
$end_time = microtime( true );
$this->logger->log( $this->endpoint_token, 'request_authentication_token', $end_time - $start_time );
@ -265,7 +359,7 @@ class OpenID_Connect_Generic_Client {
// Call the server and ask for new tokens.
$start_time = microtime( true );
$response = wp_remote_post( $this->endpoint_token, $request );
$response = $this->http_post( $this->endpoint_token, $request );
$end_time = microtime( true );
$this->logger->log( $this->endpoint_token, 'request_new_tokens', $end_time - $start_time );
@ -341,8 +435,16 @@ class OpenID_Connect_Generic_Client {
// Attempt the request including the access token in the query string for backwards compatibility.
$start_time = microtime( true );
$response = wp_remote_get( $this->endpoint_userinfo, $request );
$end_time = microtime( true );
$response = $this->http_get( $this->endpoint_userinfo, $request );
// This endpoint can support GET or POST requests according to spec, but some IDPs only allow one.
// If the GET request failed to produce valid json, attempt a POST request.
// Spec: https://openid.net/specs/openid-connect-core-1_0.html#UserInfoRequest.
if ( ! is_wp_error( $response ) && json_decode( $response['body'] ) === null ) {
$response = $this->http_post( $this->endpoint_userinfo, $request );
}
$end_time = microtime( true );
$this->logger->log( $this->endpoint_userinfo, 'request_userinfo', $end_time - $start_time );
if ( is_wp_error( $response ) ) {
@ -360,8 +462,8 @@ class OpenID_Connect_Generic_Client {
* @return string
*/
public function new_state( $redirect_to ) {
// New state w/ timestamp.
$state = md5( mt_rand() . microtime( true ) );
// New state with cryptographically secure random bytes.
$state = bin2hex( random_bytes( 16 ) );
$state_value = array(
$state => array(
'redirect_to' => $redirect_to,
@ -438,7 +540,7 @@ class OpenID_Connect_Generic_Client {
}
/**
* Extract the id_token_claim from the token_response.
* Extract and validate the id_token_claim from the token_response.
*
* @param array $token_response The token response.
*
@ -450,14 +552,47 @@ class OpenID_Connect_Generic_Client {
return new WP_Error( 'no-identity-token', __( 'No identity token.', 'daggerhart-openid-connect-generic' ), $token_response );
}
// Break apart the id_token in the response for decoding.
// Check if JWKS endpoint is configured for JWT signature verification.
if ( ! empty( $this->endpoint_jwks ) ) {
// Use configured issuer if provided, otherwise derive from endpoint_login.
$issuer = ! empty( $this->issuer )
? $this->issuer
: $this->get_issuer_from_endpoint( $this->endpoint_login );
// Use JWT validator for secure signature verification.
$jwt_validator = new OpenID_Connect_Generic_JWT_Validator(
$this->endpoint_jwks,
$this->client_id,
$issuer,
$this->jwks_cache_ttl,
$this->allow_internal_idp,
$this->logger
);
// Validate JWT signature and claims.
$id_token_claim = $jwt_validator->validate_id_token( $token_response['id_token'] );
if ( is_wp_error( $id_token_claim ) ) {
$this->logger->log( $id_token_claim, 'jwt-validation-failed' );
return $id_token_claim;
}
return $id_token_claim;
}
$this->logger->log(
'SECURITY WARNING: JWKS endpoint not configured. JWT signatures are NOT being verified. This is a critical security vulnerability. Configure the JWKS endpoint immediately in Settings > OpenID Connect Client to secure authentication.',
'jwks-not-configured-insecure'
);
// Legacy JWT decoding without signature verification (INSECURE).
$tmp = explode( '.', $token_response['id_token'] );
if ( ! isset( $tmp[1] ) ) {
return new WP_Error( 'missing-identity-token', __( 'Missing identity token.', 'daggerhart-openid-connect-generic' ), $token_response );
}
// Extract the id_token's claims from the token.
// Extract the id_token's claims from the token (no signature verification).
$id_token_claim = json_decode(
base64_decode(
str_replace( // Because token is encoded in base64 URL (and not just base64).
@ -472,6 +607,38 @@ class OpenID_Connect_Generic_Client {
return $id_token_claim;
}
/**
* Extract issuer URL from endpoint URL.
*
* The issuer is typically the base URL (scheme + host + trailing slash).
*
* @param string $endpoint_url The full endpoint URL.
*
* @return string The issuer URL.
*/
public function get_issuer_from_endpoint( $endpoint_url ) {
$parsed = wp_parse_url( $endpoint_url );
if ( ! $parsed || ! isset( $parsed['scheme'] ) || ! isset( $parsed['host'] ) ) {
return $endpoint_url;
}
$issuer = $parsed['scheme'] . '://' . $parsed['host'];
// Add port if non-standard.
if ( isset( $parsed['port'] ) ) {
$default_ports = array(
'http' => 80,
'https' => 443,
);
if ( ! isset( $default_ports[ $parsed['scheme'] ] ) || $parsed['port'] != $default_ports[ $parsed['scheme'] ] ) {
$issuer .= ':' . $parsed['port'];
}
}
return $issuer;
}
/**
* Ensure the id_token_claim contains the required values.
*
@ -484,11 +651,71 @@ class OpenID_Connect_Generic_Client {
return new WP_Error( 'bad-id-token-claim', __( 'Bad ID token claim.', 'daggerhart-openid-connect-generic' ), $id_token_claim );
}
// Validate the identification data and it's value.
// Validate the identification data and its value.
if ( ! isset( $id_token_claim['sub'] ) || empty( $id_token_claim['sub'] ) ) {
return new WP_Error( 'no-subject-identity', __( 'No subject identity.', 'daggerhart-openid-connect-generic' ), $id_token_claim );
}
// Validate expiration claim.
if ( ! isset( $id_token_claim['exp'] ) ) {
return new WP_Error( 'missing-exp', __( 'Token missing expiration claim.', 'daggerhart-openid-connect-generic' ), $id_token_claim );
}
if ( time() >= $id_token_claim['exp'] ) {
return new WP_Error( 'token-expired', __( 'Token has expired.', 'daggerhart-openid-connect-generic' ), $id_token_claim );
}
// Validate issued at claim.
if ( ! isset( $id_token_claim['iat'] ) ) {
return new WP_Error( 'missing-iat', __( 'Token missing issued at claim.', 'daggerhart-openid-connect-generic' ), $id_token_claim );
}
// Validate audience claim matches client_id (can be string or array).
if ( ! isset( $id_token_claim['aud'] ) ) {
return new WP_Error( 'missing-aud', __( 'Token missing audience claim.', 'daggerhart-openid-connect-generic' ), $id_token_claim );
}
$aud = $id_token_claim['aud'];
$audience_valid = false;
if ( is_array( $aud ) ) {
$audience_valid = in_array( $this->client_id, $aud, true );
} elseif ( is_string( $aud ) ) {
$audience_valid = ( $aud === $this->client_id );
}
if ( ! $audience_valid ) {
return new WP_Error( 'invalid-aud', __( 'Token audience does not match client.', 'daggerhart-openid-connect-generic' ), $id_token_claim );
}
// Validate issuer claim if configured or endpoint_login is available.
$expected_issuer = ! empty( $this->issuer ) ?
$this->issuer :
( ! empty( $this->endpoint_login ) ? $this->get_issuer_from_endpoint( $this->endpoint_login ) : '' );
if ( ! empty( $expected_issuer ) ) {
if ( ! isset( $id_token_claim['iss'] ) ) {
return new WP_Error( 'missing-iss', __( 'Token missing issuer claim.', 'daggerhart-openid-connect-generic' ), $id_token_claim );
}
if ( rtrim( $id_token_claim['iss'], '/' ) !== rtrim( $expected_issuer, '/' ) ) {
$this->logger->log(
sprintf(
'Issuer mismatch - Expected: "%s", Received: "%s". Configure the correct issuer in Settings > OpenID Connect Client > Issuer field, or via the OIDC_ISSUER constant.',
$expected_issuer,
$id_token_claim['iss']
),
'issuer-mismatch'
);
return new WP_Error(
'invalid-iss',
sprintf(
__( 'Token issuer does not match expected issuer.', 'daggerhart-openid-connect-generic' ),
),
$id_token_claim
);
}
}
// Validate acr values when the option is set in the configuration.
if ( ! empty( $this->acr_values ) && isset( $id_token_claim['acr'] ) ) {
if ( $this->acr_values != $id_token_claim['acr'] ) {

View File

@ -0,0 +1,364 @@
<?php
/**
* JWT validation and verification class.
*
* @package OpenID_Connect_Generic
* @category Authentication
* @author Jonathan Daggerhart <jonathan@daggerhart.com>
* @copyright 2015-2020 daggerhart
* @license http://www.gnu.org/licenses/gpl-2.0.txt GPL-2.0+
*/
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;
/**
* OpenID_Connect_Generic_JWT_Validator class.
*
* Handles JWT signature verification and claim validation using JWKS.
*
* @package OpenID_Connect_Generic
* @category Authentication
*/
class OpenID_Connect_Generic_JWT_Validator {
/**
* The JWKS endpoint URL.
*
* @var string
*/
private $jwks_uri;
/**
* The expected client ID (audience).
*
* @var string
*/
private $client_id;
/**
* The expected issuer.
*
* @var string
*/
private $issuer;
/**
* JWKS cache TTL in seconds.
*
* @var int
*/
private $cache_ttl;
/**
* Allow HTTP requests to internal/private network endpoints.
*
* @var bool
*/
private $allow_internal_idp;
/**
* Logger instance.
*
* @var OpenID_Connect_Generic_Option_Logger
*/
private $logger;
/**
* Constructor.
*
* @param string $jwks_uri The JWKS endpoint URL.
* @param string $client_id The client ID for audience validation.
* @param string $issuer The expected issuer.
* @param int $cache_ttl JWKS cache TTL in seconds.
* @param bool $allow_internal_idp Allow internal/private network endpoints.
* @param OpenID_Connect_Generic_Option_Logger $logger Logger instance.
*/
public function __construct( $jwks_uri, $client_id, $issuer, $cache_ttl, $allow_internal_idp, $logger ) {
$this->jwks_uri = $jwks_uri;
$this->client_id = $client_id;
$this->issuer = $issuer;
$this->cache_ttl = $cache_ttl;
$this->allow_internal_idp = $allow_internal_idp;
$this->logger = $logger;
}
/**
* Make a safe HTTP GET request with optional internal endpoint support.
*
* By default, uses wp_safe_remote_get() which blocks requests to internal/private
* networks (SSRF protection). If allow_internal_idp is enabled, uses wp_remote_get()
* to allow connections to localhost and private network identity providers.
*
* @param string $url The URL to request.
* @param array $args Optional. Request arguments.
*
* @return array|WP_Error Response array or WP_Error on failure.
*/
private function http_get( $url, $args = array() ) {
if ( $this->allow_internal_idp ) {
return wp_remote_get( $url, $args );
}
return wp_safe_remote_get( $url, $args );
}
/**
* Fetch JWKS from the IDP endpoint with caching.
*
* @return array|WP_Error Array of keys or WP_Error on failure.
*/
private function fetch_jwks() {
// Check cache first.
$cache_key = 'openid_connect_jwks_' . md5( $this->jwks_uri );
$cached_jwks = get_transient( $cache_key );
if ( false !== $cached_jwks ) {
return $cached_jwks;
}
// Fetch JWKS from IDP.
$response = $this->http_get( $this->jwks_uri, array( 'timeout' => 10 ) );
if ( is_wp_error( $response ) ) {
$this->logger->log( $response, 'jwks-fetch-failed' );
return new WP_Error(
'jwks-fetch-failed',
__( 'Failed to fetch JWKS from identity provider.', 'daggerhart-openid-connect-generic' ),
$response
);
}
$response_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $response_code ) {
$error = new WP_Error(
'jwks-fetch-failed',
sprintf(
/* translators: %d is the HTTP response code */
__( 'JWKS endpoint returned HTTP %d', 'daggerhart-openid-connect-generic' ),
$response_code
)
);
$this->logger->log( $error, 'jwks-fetch-failed' );
return $error;
}
$body = wp_remote_retrieve_body( $response );
$jwks = json_decode( $body, true );
if ( ! $jwks || ! isset( $jwks['keys'] ) ) {
$error = new WP_Error(
'jwks-invalid-format',
__( 'Invalid JWKS format received from identity provider.', 'daggerhart-openid-connect-generic' )
);
$this->logger->log( $error, 'jwks-invalid-format' );
return $error;
}
// Cache the JWKS.
set_transient( $cache_key, $jwks, $this->cache_ttl );
return $jwks;
}
/**
* Validate JWT claims.
*
* @param object $decoded_jwt The decoded JWT payload.
*
* @return true|WP_Error True if valid, WP_Error on failure.
*/
private function validate_jwt_claims( $decoded_jwt ) {
// Validate subject (sub) claim.
if ( ! isset( $decoded_jwt->sub ) || empty( $decoded_jwt->sub ) ) {
return new WP_Error(
'missing-sub',
__( 'Token missing subject claim.', 'daggerhart-openid-connect-generic' )
);
}
// Validate expiration (exp) claim - JWT library already validates this, but double-check.
if ( ! isset( $decoded_jwt->exp ) ) {
return new WP_Error(
'missing-exp',
__( 'Token missing expiration claim.', 'daggerhart-openid-connect-generic' )
);
}
// Validate issued at (iat) claim.
if ( ! isset( $decoded_jwt->iat ) ) {
return new WP_Error(
'missing-iat',
__( 'Token missing issued at claim.', 'daggerhart-openid-connect-generic' )
);
}
// Validate audience (aud) claim.
if ( ! isset( $decoded_jwt->aud ) ) {
return new WP_Error(
'missing-aud',
__( 'Token missing audience claim.', 'daggerhart-openid-connect-generic' )
);
}
// Audience can be string or array.
$aud = $decoded_jwt->aud;
$audience_valid = false;
if ( is_array( $aud ) ) {
$audience_valid = in_array( $this->client_id, $aud, true );
} elseif ( is_string( $aud ) ) {
$audience_valid = ( $aud === $this->client_id );
}
if ( ! $audience_valid ) {
return new WP_Error(
'invalid-aud',
__( 'Token audience does not match client.', 'daggerhart-openid-connect-generic' )
);
}
// Validate issuer (iss) claim if configured.
if ( ! empty( $this->issuer ) ) {
if ( ! isset( $decoded_jwt->iss ) ) {
return new WP_Error(
'missing-iss',
__( 'Token missing issuer claim.', 'daggerhart-openid-connect-generic' )
);
}
if ( rtrim( $decoded_jwt->iss, '/' ) !== rtrim( $this->issuer, '/' ) ) {
$this->logger->log(
sprintf(
'Issuer mismatch - Expected: "%s", Received: "%s". Configure the correct issuer in Settings > OpenID Connect Client > Issuer field, or via the OIDC_ISSUER constant.',
$this->issuer,
$decoded_jwt->iss
),
'issuer-mismatch'
);
return new WP_Error(
'invalid-iss',
__( 'Token issuer does not match expected issuer.', 'daggerhart-openid-connect-generic' )
);
}
}
return true;
}
/**
* Extract the algorithm from JWT header.
*
* @param string $id_token The JWT ID token.
*
* @return string|null The algorithm from JWT header or null if not found.
*/
private function get_jwt_header_alg( $id_token ) {
$token_parts = explode( '.', $id_token );
if ( count( $token_parts ) < 2 ) {
return null;
}
$header_base64 = $token_parts[0];
$header_json = JWT::urlsafeB64Decode( $header_base64 );
$header = json_decode( $header_json, true );
return isset( $header['alg'] ) ? $header['alg'] : null;
}
/**
* Enrich JWKS with algorithm from JWT header if missing.
*
* Some identity providers (like Microsoft Entra ID) return JWKs without
* the "alg" parameter. This method adds the algorithm from the JWT header
* to each key that's missing it, ensuring compatibility with the Firebase
* JWT library which requires "alg" to be present.
*
* @param array $jwks The JWKS array with keys.
* @param string $id_token The JWT ID token.
*
* @return array The enriched JWKS array.
*/
private function enrich_jwks_with_alg( $jwks, $id_token ) {
// Extract algorithm from JWT header.
$jwt_alg = $this->get_jwt_header_alg( $id_token );
// If we couldn't extract the algorithm, default to RS256 (most common for OIDC).
if ( empty( $jwt_alg ) ) {
$jwt_alg = 'RS256';
}
// Add algorithm to keys that are missing it.
if ( isset( $jwks['keys'] ) && is_array( $jwks['keys'] ) ) {
foreach ( $jwks['keys'] as &$key ) {
if ( ! isset( $key['alg'] ) ) {
$key['alg'] = $jwt_alg;
}
}
}
return $jwks;
}
/**
* Validate and verify an ID token.
*
* @param string $id_token The JWT ID token to validate.
*
* @return array|WP_Error Array of claims if valid, WP_Error on failure.
*/
public function validate_id_token( $id_token ) {
// Check if JWKS URI is configured.
if ( empty( $this->jwks_uri ) ) {
$error = new WP_Error(
'jwks-not-configured',
__( 'JWKS URI not configured. JWT signature verification requires JWKS endpoint.', 'daggerhart-openid-connect-generic' )
);
$this->logger->log( $error, 'jwks-not-configured' );
return $error;
}
// Fetch JWKS.
$jwks = $this->fetch_jwks();
if ( is_wp_error( $jwks ) ) {
return $jwks;
}
// Enrich JWKS with algorithm from JWT header if keys are missing "alg".
// This ensures compatibility with providers like Microsoft Entra ID that
// don't include "alg" in their JWKS.
$jwks = $this->enrich_jwks_with_alg( $jwks, $id_token );
// Verify JWT signature and decode.
try {
// Parse JWKS into Key objects.
$keys = JWK::parseKeySet( $jwks );
// Decode and verify JWT signature.
// The JWT library will automatically validate exp, nbf, and signature.
$decoded_jwt = JWT::decode( $id_token, $keys );
} catch ( Exception $e ) {
$error = new WP_Error(
'jwt-verification-failed',
sprintf(
/* translators: %s is the error message */
__( 'JWT verification failed: %s', 'daggerhart-openid-connect-generic' ),
$e->getMessage()
)
);
$this->logger->log( $error, 'jwt-verification-failed' );
return $error;
}
// Validate additional claims.
$claims_valid = $this->validate_jwt_claims( $decoded_jwt );
if ( is_wp_error( $claims_valid ) ) {
$this->logger->log( $claims_valid, 'jwt-claims-invalid' );
return $claims_valid;
}
// Convert stdClass to array for consistency with existing code.
return json_decode( json_encode( $decoded_jwt ), true );
}
}

View File

@ -33,15 +33,24 @@ class OpenID_Connect_Generic_Login_Form {
*/
private $client_wrapper;
/**
* The client object instance.
*
* @var OpenID_Connect_Generic_Client
*/
private $client;
/**
* The class constructor.
*
* @param OpenID_Connect_Generic_Option_Settings $settings A plugin settings object instance.
* @param OpenID_Connect_Generic_Client_Wrapper $client_wrapper A plugin client wrapper object instance.
* @param OpenID_Connect_Generic_Client $client A plugin client object instance.
*/
public function __construct( $settings, $client_wrapper ) {
public function __construct( $settings, $client_wrapper, $client ) {
$this->settings = $settings;
$this->client_wrapper = $client_wrapper;
$this->client = $client;
}
/**
@ -49,11 +58,12 @@ class OpenID_Connect_Generic_Login_Form {
*
* @param OpenID_Connect_Generic_Option_Settings $settings A plugin settings object instance.
* @param OpenID_Connect_Generic_Client_Wrapper $client_wrapper A plugin client wrapper object instance.
* @param OpenID_Connect_Generic_Client $client A plugin client object instance.
*
* @return void
*/
public static function register( $settings, $client_wrapper ) {
$login_form = new self( $settings, $client_wrapper );
public static function register( $settings, $client_wrapper, $client ) {
$login_form = new self( $settings, $client_wrapper, $client );
// Alter the login form as dictated by settings.
add_filter( 'login_message', array( $login_form, 'handle_login_page' ), 99 );
@ -136,9 +146,20 @@ class OpenID_Connect_Generic_Login_Form {
*/
public function make_login_button( $atts = array() ) {
// Use admin-configured button text, or fall back to default.
$default_button_text = ! empty( trim( $this->settings->login_button_text ?? '' ) )
? $this->settings->login_button_text
: __( 'Login with OpenID Connect', 'daggerhart-openid-connect-generic' );
$atts = shortcode_atts(
array(
'button_text' => __( 'Login with OpenID Connect', 'daggerhart-openid-connect-generic' ),
'button_text' => $default_button_text,
'endpoint_login' => $this->settings->endpoint_login,
'scope' => $this->settings->scope,
'client_id' => $this->settings->client_id,
'redirect_uri' => $this->client->get_redirect_uri(),
'redirect_to' => $this->client_wrapper->get_redirect_to(),
'acr_values' => $this->settings->acr_values,
),
$atts,
'openid_connect_generic_login_button'
@ -147,7 +168,16 @@ class OpenID_Connect_Generic_Login_Form {
$text = apply_filters( 'openid-connect-generic-login-button-text', $atts['button_text'] );
$text = esc_html( $text );
$href = $this->client_wrapper->get_authentication_url( $atts );
$href = $this->client_wrapper->get_authentication_url(
array(
'endpoint_login' => $atts['endpoint_login'],
'scope' => $atts['scope'],
'client_id' => $atts['client_id'],
'redirect_uri' => $atts['redirect_uri'],
'redirect_to' => $atts['redirect_to'],
'acr_values' => $atts['acr_values'],
)
);
$href = esc_url_raw( $href );
$login_button = <<<HTML

View File

@ -174,7 +174,9 @@ class OpenID_Connect_Generic_Option_Logger {
* @return array
*/
private function upkeep_logs( $logs ) {
$items_to_remove = count( $logs ) - $this->log_limit;
$items_to_remove = is_array( $logs ) ?
count( $logs ) - $this->log_limit :
0;
if ( $items_to_remove > 0 ) {
// Only keep the last $log_limit messages from the end.
@ -250,10 +252,13 @@ class OpenID_Connect_Generic_Option_Logger {
</div>
<div>
<label><?php esc_html_e( 'Response&nbsp;Time&nbsp;(sec)', 'daggerhart-openid-connect-generic' ); ?></label>
<?php print esc_html( ! empty( $log['response_time'] ) ? $log['response_time'] : '' ); ?>
<?php print esc_html( ! empty( $log['processing_time'] ) ? $log['processing_time'] : 0 ); ?>
</div>
</td>
<td class="col-data"><pre><?php var_dump( ! empty( $log['data'] ) ? $log['data'] : '' ); ?></pre></td>
<td class="col-data">
<?php $log_data = ! empty( $log['data'] ) ? print_r( $log['data'], true ) : ''; ?>
<pre><?php print esc_html( $log_data ); ?></pre>
</td>
</tr>
<?php } ?>
</tbody>

View File

@ -26,6 +26,7 @@
* OAuth Client Settings:
*
* @property string $login_type How the client (login form) should provide login options.
* @property string $login_button_text Customizable text for the OpenID Connect login button.
* @property string $client_id The ID the client will be recognized as when connecting the to Identity provider server.
* @property string $client_secret The secret key the IDP server expects from the client.
* @property string $scope The list of scopes this client should access.
@ -33,12 +34,16 @@
* @property string $endpoint_userinfo The IDP User information endpoint URL.
* @property string $endpoint_token The IDP token validation endpoint URL.
* @property string $endpoint_end_session The IDP logout endpoint URL.
* @property string $endpoint_jwks The IDP JWKS endpoint URL for JWT signature verification.
* @property string $issuer The IDP issuer URL for JWT validation (optional - derived from endpoint_login if not set).
* @property int $jwks_cache_ttl The JWKS cache TTL in seconds.
* @property string $acr_values The Authentication contract as defined on the IDP.
*
* Non-standard Settings:
*
* @property bool $no_sslverify The flag to enable/disable SSL verification during authorization.
* @property int $http_request_timeout The timeout for requests made to the IDP. Default value is 5.
* @property bool $allow_internal_idp The flag to allow HTTP requests to internal/private network endpoints. Default is false.
* @property string $identity_key The key in the user claim array to find the user's identification data.
* @property string $nickname_key The key in the user claim array to find the user's nickname.
* @property string $email_format The key(s) in the user claim array to formulate the user's email address.
@ -93,6 +98,8 @@ class OpenID_Connect_Generic_Option_Settings {
'endpoint_login' => 'OIDC_ENDPOINT_LOGIN_URL',
'endpoint_token' => 'OIDC_ENDPOINT_TOKEN_URL',
'endpoint_userinfo' => 'OIDC_ENDPOINT_USERINFO_URL',
'endpoint_jwks' => 'OIDC_ENDPOINT_JWKS_URL',
'issuer' => 'OIDC_ISSUER',
'login_type' => 'OIDC_LOGIN_TYPE',
'scope' => 'OIDC_CLIENT_SCOPE',
'create_if_does_not_exist' => 'OIDC_CREATE_IF_DOES_NOT_EXIST',

View File

@ -79,6 +79,25 @@ class OpenID_Connect_Generic_Settings_Page {
$this->settings_fields = $fields;
}
/**
* Make a safe HTTP GET request with optional internal endpoint support.
*
* By default, uses wp_safe_remote_get() which blocks requests to internal/private
* networks (SSRF protection). If allow_internal_idp is enabled, uses wp_remote_get()
* to allow connections to localhost and private network identity providers.
*
* @param string $url The URL to request.
* @param array $args Optional. Request arguments.
*
* @return array|WP_Error Response array or WP_Error on failure.
*/
private function http_get( $url, $args = array() ) {
if ( $this->settings->allow_internal_idp ) {
return wp_remote_get( $url, $args );
}
return wp_safe_remote_get( $url, $args );
}
/**
* Hook the settings page into WordPress.
*
@ -219,6 +238,13 @@ class OpenID_Connect_Generic_Settings_Page {
'disabled' => defined( 'OIDC_LOGIN_TYPE' ),
'section' => 'client_settings',
),
'login_button_text' => array(
'title' => __( 'Login Button Text', 'daggerhart-openid-connect-generic' ),
'description' => __( 'Customize the text shown on the OpenID Connect login button. Leave empty to use the default text.', 'daggerhart-openid-connect-generic' ),
'example' => 'Login with Single Sign-On',
'type' => 'text',
'section' => 'client_settings',
),
'client_id' => array(
'title' => __( 'Client ID', 'daggerhart-openid-connect-generic' ),
'description' => __( 'The ID this client will be recognized as when connecting the to Identity provider server.', 'daggerhart-openid-connect-generic' ),
@ -274,6 +300,29 @@ class OpenID_Connect_Generic_Settings_Page {
'disabled' => defined( 'OIDC_ENDPOINT_LOGOUT_URL' ),
'section' => 'client_settings',
),
'endpoint_jwks' => array(
'title' => __( 'JWKS URI', 'daggerhart-openid-connect-generic' ),
'description' => __( 'Identity provider JWKS (JSON Web Key Set) endpoint for JWT signature verification. Usually found at /.well-known/jwks.json', 'daggerhart-openid-connect-generic' ),
'example' => 'https://example.com/.well-known/jwks.json',
'type' => 'text',
'disabled' => defined( 'OIDC_ENDPOINT_JWKS_URL' ),
'section' => 'client_settings',
),
'issuer' => array(
'title' => __( 'Issuer', 'daggerhart-openid-connect-generic' ),
'description' => __( 'Identity provider issuer URL for JWT validation. If not set, the issuer will be automatically derived from the Login Endpoint URL. Only configure this if your IDP uses a different issuer than the base URL of the login endpoint.', 'daggerhart-openid-connect-generic' ),
'example' => 'https://example.com',
'type' => 'text',
'disabled' => defined( 'OIDC_ISSUER' ),
'section' => 'client_settings',
),
'jwks_cache_ttl' => array(
'title' => __( 'JWKS Cache TTL (seconds)', 'daggerhart-openid-connect-generic' ),
'description' => __( 'Time in seconds to cache JWKS keys. Default: 3600 (1 hour)', 'daggerhart-openid-connect-generic' ),
'example' => 3600,
'type' => 'number',
'section' => 'client_settings',
),
'acr_values' => array(
'title' => __( 'ACR values', 'daggerhart-openid-connect-generic' ),
'description' => __( 'Use a specific defined authentication contract from the IDP - optional.', 'daggerhart-openid-connect-generic' ),
@ -288,13 +337,6 @@ class OpenID_Connect_Generic_Settings_Page {
'type' => 'text',
'section' => 'client_settings',
),
'no_sslverify' => array(
'title' => __( 'Disable SSL Verify', 'daggerhart-openid-connect-generic' ),
// translators: %1$s HTML tags for layout/styles, %2$s closing HTML tag for styles.
'description' => sprintf( __( 'Do not require SSL verification during authorization. The OAuth extension uses curl to make the request. By default CURL will generally verify the SSL certificate to see if its valid an issued by an accepted CA. This setting disabled that verification.%1$sNot recommended for production sites.%2$s', 'daggerhart-openid-connect-generic' ), '<br><strong>', '</strong>' ),
'type' => 'checkbox',
'section' => 'client_settings',
),
'http_request_timeout' => array(
'title' => __( 'HTTP Request Timeout', 'daggerhart-openid-connect-generic' ),
'description' => __( 'Set the timeout for requests made to the IDP. Default value is 5.', 'daggerhart-openid-connect-generic' ),
@ -354,6 +396,20 @@ class OpenID_Connect_Generic_Settings_Page {
'type' => 'checkbox',
'section' => 'client_settings',
),
'no_sslverify' => array(
'title' => __( 'Disable SSL Verify', 'daggerhart-openid-connect-generic' ),
// translators: %1$s HTML tags for layout/styles (strong tag start with warning class), %2$s closing HTML tag for styles.
'description' => sprintf( __( 'Do not require SSL verification during authorization. %1$sOnly works in local development (WP_DEBUG=true, WP_ENVIRONMENT_TYPE=local).%2$s This setting is automatically disabled in production. If you need this in production, fix your SSL certificates instead.', 'daggerhart-openid-connect-generic' ), '<br><strong class="oidc-warning">', '</strong>' ),
'type' => 'checkbox',
'section' => 'client_settings',
),
'allow_internal_idp' => array(
'title' => __( 'Allow Internal IDP', 'daggerhart-openid-connect-generic' ),
// translators: %1$s HTML tags for layout/styles (strong tag start with warning class), %2$s closing HTML tag for styles.
'description' => sprintf( __( 'Allow HTTP requests to internal/private network endpoints (localhost, 127.0.0.1, 10.x.x.x, 192.168.x.x, 172.16-31.x.x). %1$sOnly enable this for local development or corporate internal identity providers. Disabling SSRF protection can expose your server to security risks.%2$s', 'daggerhart-openid-connect-generic' ), '<br><strong class="oidc-warning">', '</strong>' ),
'type' => 'checkbox',
'section' => 'client_settings',
),
'link_existing_users' => array(
'title' => __( 'Link Existing Users', 'daggerhart-openid-connect-generic' ),
'description' => __( 'If a WordPress account already exists with the same identity as a newly-authenticated user over OpenID Connect, login as that user instead of generating an error.', 'daggerhart-openid-connect-generic' ),
@ -429,6 +485,9 @@ class OpenID_Connect_Generic_Settings_Page {
* @return void
*/
public function settings_page() {
// Handle discovery form submission before any output.
$this->handle_discovery_import();
wp_enqueue_style( 'daggerhart-openid-connect-generic-admin', plugin_dir_url( __DIR__ ) . 'css/styles-admin.css', array(), OpenID_Connect_Generic::VERSION, 'all' );
$redirect_uri = admin_url( 'admin-ajax.php?action=openid-connect-authorize' );
@ -440,16 +499,16 @@ class OpenID_Connect_Generic_Settings_Page {
<div class="wrap">
<h2><?php print esc_html( get_admin_page_title() ); ?></h2>
<?php
// Render discovery document import form.
$this->render_discovery_form();
?>
<form method="post" action="options.php">
<?php
settings_fields( $this->settings_field_group );
do_settings_sections( $this->options_page_name );
submit_button();
// Simple debug to view settings array.
if ( isset( $_GET['debug'] ) ) {
var_dump( $this->settings->get_values() );
}
?>
</form>
@ -600,4 +659,281 @@ class OpenID_Connect_Generic_Settings_Page {
public function log_settings_description() {
esc_html_e( 'Log information about login attempts through OpenID Connect Generic.', 'daggerhart-openid-connect-generic' );
}
/**
* Fetch OpenID Connect discovery document from provider.
*
* @param string $discovery_url The discovery document URL (.well-known/openid-configuration).
*
* @return array|WP_Error Array of discovery data on success, WP_Error on failure.
*/
private function fetch_discovery_document( $discovery_url ) {
// Validate URL is provided.
if ( empty( $discovery_url ) ) {
return new WP_Error(
'empty-discovery-url',
__( 'Please enter a discovery URL.', 'daggerhart-openid-connect-generic' )
);
}
// Validate HTTPS in production.
$parsed_url = wp_parse_url( $discovery_url );
if ( ! $parsed_url || ! isset( $parsed_url['scheme'] ) ) {
return new WP_Error(
'invalid-discovery-url',
__( 'Invalid discovery URL format.', 'daggerhart-openid-connect-generic' )
);
}
// Require HTTPS except in local development.
$is_local_dev = defined( 'WP_DEBUG' ) && WP_DEBUG === true &&
( ! defined( 'WP_ENVIRONMENT_TYPE' ) || WP_ENVIRONMENT_TYPE === 'local' );
if ( 'https' !== $parsed_url['scheme'] && ! $is_local_dev ) {
return new WP_Error(
'discovery-url-not-https',
__( 'Discovery URL must use HTTPS in production environments.', 'daggerhart-openid-connect-generic' )
);
}
// Fetch discovery document.
$response = $this->http_get(
$discovery_url,
array(
'timeout' => 10,
'headers' => array(
'Accept' => 'application/json',
),
)
);
if ( is_wp_error( $response ) ) {
return new WP_Error(
'discovery-fetch-failed',
sprintf(
/* translators: %s: error message */
__( 'Failed to fetch discovery document: %s', 'daggerhart-openid-connect-generic' ),
$response->get_error_message()
)
);
}
$response_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $response_code ) {
return new WP_Error(
'discovery-fetch-failed',
sprintf(
/* translators: %d: HTTP status code */
__( 'Discovery document request returned HTTP %d.', 'daggerhart-openid-connect-generic' ),
$response_code
)
);
}
// Parse JSON response.
$body = wp_remote_retrieve_body( $response );
$discovery = json_decode( $body, true );
if ( null === $discovery || ! is_array( $discovery ) ) {
return new WP_Error(
'discovery-invalid-json',
__( 'Discovery document is not valid JSON.', 'daggerhart-openid-connect-generic' )
);
}
// Validate required fields are present.
$required_fields = array( 'authorization_endpoint', 'token_endpoint', 'jwks_uri' );
$missing_fields = array();
foreach ( $required_fields as $field ) {
if ( ! isset( $discovery[ $field ] ) || empty( $discovery[ $field ] ) ) {
$missing_fields[] = $field;
}
}
if ( ! empty( $missing_fields ) ) {
return new WP_Error(
'discovery-missing-fields',
sprintf(
/* translators: %s: comma-separated list of missing fields */
__( 'Discovery document is missing required fields: %s', 'daggerhart-openid-connect-generic' ),
implode( ', ', $missing_fields )
)
);
}
return $discovery;
}
/**
* Populate plugin settings from discovery document.
*
* Maps discovery document fields to plugin setting keys.
* Does not save to database - only updates in-memory values.
*
* @param array $discovery The discovery document data.
*
* @return array Array of setting keys that were populated.
*/
private function populate_settings_from_discovery( $discovery ) {
$populated_fields = array();
// Map discovery fields to plugin settings.
$field_mapping = array(
'authorization_endpoint' => 'endpoint_login',
'token_endpoint' => 'endpoint_token',
'userinfo_endpoint' => 'endpoint_userinfo',
'jwks_uri' => 'endpoint_jwks',
'issuer' => 'issuer',
'end_session_endpoint' => 'endpoint_end_session',
);
foreach ( $field_mapping as $discovery_key => $setting_key ) {
if ( isset( $discovery[ $discovery_key ] ) && ! empty( $discovery[ $discovery_key ] ) ) {
// Update the setting value (not saved yet).
$this->settings->{ $setting_key } = $discovery[ $discovery_key ];
$populated_fields[] = $setting_key;
}
}
return $populated_fields;
}
/**
* Handle discovery document import form submission.
*
* Checks if the discovery form was submitted, validates it,
* fetches the discovery document, and populates settings.
*
* @return void
*/
private function handle_discovery_import() {
// Check if discovery form was submitted.
if ( ! isset( $_POST['oidc_discovery_submit'] ) ) {
return;
}
// Verify nonce.
if (
! isset( $_POST['oidc_discovery_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['oidc_discovery_nonce'] ) ), 'oidc_discovery_import' )
) {
add_settings_error(
'openid-connect-generic',
'invalid-nonce',
__( 'Security check failed. Please try again.', 'daggerhart-openid-connect-generic' ),
'error'
);
return;
}
// Get discovery URL from form.
$discovery_url = isset( $_POST['oidc_discovery_url'] )
? esc_url_raw( wp_unslash( $_POST['oidc_discovery_url'] ) )
: '';
// Fetch discovery document.
$discovery = $this->fetch_discovery_document( $discovery_url );
if ( is_wp_error( $discovery ) ) {
add_settings_error(
'openid-connect-generic',
$discovery->get_error_code(),
$discovery->get_error_message(),
'error'
);
return;
}
// Populate settings from discovery document.
$populated_fields = $this->populate_settings_from_discovery( $discovery );
// Log the import.
$this->logger->log(
sprintf(
'Configuration loaded from discovery URL: %s. Populated fields: %s',
$discovery_url,
implode( ', ', $populated_fields )
),
'discovery-import'
);
// Show success message.
$field_count = count( $populated_fields );
add_settings_error(
'openid-connect-generic',
'discovery-success',
sprintf(
/* translators: %d: number of fields populated */
_n(
'Configuration loaded successfully! %d field was populated. Review the settings below and click "Save Changes" to apply.',
'Configuration loaded successfully! %d fields were populated. Review the settings below and click "Save Changes" to apply.',
$field_count,
'daggerhart-openid-connect-generic'
),
$field_count
),
'success'
);
}
/**
* Render the discovery document import form.
*
* Outputs HTML form for importing configuration from discovery document.
* Collapsed by default if endpoint_login is already configured.
*
* @return void
*/
private function render_discovery_form() {
// Auto-expand if plugin is not yet configured.
$is_configured = ! empty( $this->settings->endpoint_login );
$open_attribute = $is_configured ? '' : ' open';
?>
<details<?php echo esc_attr( $open_attribute ); ?> class="oidc-discovery-section">
<summary class="oidc-discovery-summary">
⚡ <?php esc_html_e( 'Quick Setup: Import from Discovery Document', 'daggerhart-openid-connect-generic' ); ?>
</summary>
<div class="notice notice-info inline oidc-discovery-content">
<p>
<?php esc_html_e( 'Auto-populate endpoint settings from your identity provider\'s OpenID Connect discovery document. After loading, review the populated fields below and click "Save Changes" to apply.', 'daggerhart-openid-connect-generic' ); ?>
</p>
<form method="post" action="">
<?php wp_nonce_field( 'oidc_discovery_import', 'oidc_discovery_nonce' ); ?>
<table class="form-table">
<tr>
<th scope="row">
<label for="oidc_discovery_url">
<?php esc_html_e( 'Discovery URL', 'daggerhart-openid-connect-generic' ); ?>
</label>
</th>
<td>
<input
type="url"
id="oidc_discovery_url"
name="oidc_discovery_url"
class="regular-text oidc-discovery-url-input"
placeholder="https://your-idp.com/.well-known/openid-configuration"
/>
<p class="description">
<?php esc_html_e( 'Enter your identity provider\'s OpenID Connect discovery endpoint URL.', 'daggerhart-openid-connect-generic' ); ?>
<br>
<strong><?php esc_html_e( 'Examples:', 'daggerhart-openid-connect-generic' ); ?></strong>
<br>
• Auth0: <code>https://{tenant}.{region}.auth0.com/.well-known/openid-configuration</code>
<br>
• Keycloak: <code>https://{domain}/realms/{realm}/.well-known/openid-configuration</code>
<br>
• Okta: <code>https://{domain}/.well-known/openid-configuration</code>
</p>
</td>
</tr>
</table>
<?php submit_button( __( 'Load Configuration', 'daggerhart-openid-connect-generic' ), 'secondary', 'oidc_discovery_submit', false ); ?>
</form>
</div>
</details>
<hr class="oidc-discovery-separator">
<?php
}
}