` plus * a DPoP proof for this exact request, bound to the token's key. * * Refusals, each with its WWW-Authenticate challenge (RFC 9449 / spec §6): * - no or malformed Authorization, unknown or expired token → invalid_token 401 * - a Bearer token (only MCP takes those, spec 4.3) → invalid_token 401 * - a token or assertion in the query string (spec 4.3) → invalid_request 400 * - a bad, replayed or mis-bound proof → invalid_dpop_proof 401 * - the Session ended, or its agent is now blocked → invalid_token 401 * * @package AgentAdapt */ defined( 'ABSPATH' ) || exit; /** * API request authentication. */ final class AgentAdapt_Request_Auth { /** * Resolve the context for the current request, once. * * @param string $method Request method. * @param string $channel Channel label for events. * @return AgentAdapt_Context|WP_Error */ public static function context( string $method, string $channel = 'api' ) { if ( AgentAdapt_Http::token_in_query() ) { return new WP_Error( 'invalid_request', 'credentials are never accepted in the URL', array( 'status' => 400 ) ); } $auth = (string) AgentAdapt_Http::header( 'Authorization' ); if ( '' === $auth ) { return self::invalid_token( 'a DPoP Session Token is required' ); } if ( 0 === stripos( $auth, 'bearer ' ) ) { return self::invalid_token( 'Bearer tokens are accepted only at MCP; use DPoP' ); } if ( 0 !== stripos( $auth, 'dpop ' ) ) { return self::invalid_token( 'unsupported authorization scheme' ); } $token = trim( substr( $auth, 5 ) ); $record = AgentAdapt_Tokens::find( $token ); if ( null === $record || 'session' !== $record['kind'] || empty( $record['jkt'] ) ) { return self::invalid_token( 'the token is missing, expired or unknown' ); } $session = AgentAdapt_Sessions::get( (string) $record['session_id'] ); if ( null === $session ) { return self::invalid_token( 'the Session has ended' ); } $proof = AgentAdapt_Dpop::verify_request( $method, AgentAdapt_Urls::current_request_url(), $token, (string) $record['jkt'] ); if ( is_wp_error( $proof ) ) { return new WP_Error( 'invalid_dpop_proof', $proof->get_error_message(), array( 'status' => 401, 'www_authenticate' => 'DPoP error="invalid_dpop_proof", algs="' . implode( ' ', AgentAdapt_Jose::ALGS ) . '"' ) ); } return self::resolve( $record, $session, $channel ); } /** * Everything after the token and its proof: the agent is still accepted, * and the request is signed in only while the Session is signed in to the * account this token was issued for and that account is still eligible. * Every channel that authenticates a Session Token ends here, so the * rules are the same on all of them. * * @param array $record Token record (AgentAdapt_Tokens::find()). * @param array $session Its live Session (AgentAdapt_Sessions::get()). * @param string $channel Channel label for events. * @return AgentAdapt_Context|WP_Error */ public static function resolve( array $record, array $session, string $channel ) { global $wpdb; $t = AgentAdapt_Schema::table( 'agents' ); $agent = $wpdb->get_row( $wpdb->prepare( "SELECT client_id, client_hash, name, metadata, status FROM %i WHERE client_hash = %s", $t, $session['client_hash'] ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( ! $agent || ! AgentAdapt_Agents::accepted( (string) $agent['client_hash'], (string) $agent['status'] ) ) { return self::invalid_token( 'this agent is not accepted' ); } $meta = json_decode( (string) $agent['metadata'], true ); // A public client's Session has no User ID and can't continue signed // out: once its sign-in is revoked or expires, it has ended (spec §6). if ( is_array( $meta ) && AgentAdapt_Agents::is_public( $meta ) && AgentAdapt_Sessions::end_if_signed_out( $session ) ) { return self::invalid_token( 'the Session has ended' ); } AgentAdapt_Sessions::touch( $session ); // Signed in only while ALL hold, checked on every request (spec 4.9 // SHOULD; PLAN.md §5.4 rule 2): the Session is still signed in, to the // account this token was issued for, and that account is still // eligible. Otherwise the request is handled as signed out. $user_id = (int) $session['user_id']; $scopes = array_values( array_filter( explode( ' ', (string) $record['scope'] ) ) ); if ( $user_id < 1 || (int) $record['user_id'] !== $user_id ) { $user_id = 0; $scopes = array(); } elseif ( ! AgentAdapt_Eligibility::eligible( $user_id ) ) { AgentAdapt_Grants::revoke_user( $user_id, 'no_longer_eligible' ); $user_id = 0; $scopes = array(); } return new AgentAdapt_Context( (string) $agent['client_id'], (string) $agent['client_hash'], (string) $agent['name'], is_array( $meta ) ? $meta : array(), (int) $session['subject_id'], (string) $session['session_id'], (string) $session['wc_session_key'], $user_id, $scopes, $channel ); } /** * invalid_token, with its challenge. * * @param string $why Description (no account data). */ private static function invalid_token( string $why ): WP_Error { return new WP_Error( 'invalid_token', $why, array( 'status' => 401, 'www_authenticate' => 'DPoP error="invalid_token", algs="' . implode( ' ', AgentAdapt_Jose::ALGS ) . '"' ) ); } }