` (never in the * URL), sent by the agent's browser as a top-level navigation. The assertion * is signed with a key from the agent's jwks_uri, `typ: poppy-browser+jwt`, * aud = this endpoint, exp at most 60 s after iat, single-use jti, naming a * live Session of the same agent and User, and a `return_to` on this store's * own domain. Pass: the store sets ITS OWN cookie for the Session and answers * 303 to return_to. Fail: no cookie, HTTP 400. A new assertion replaces the * cookie. What the cookie then does is AgentAdapt_Web_Guard. * * Request bodies here are never logged (Guides): they carry assertions. * * @package AgentAdapt */ defined( 'ABSPATH' ) || exit; /** * Browser session endpoint. */ final class AgentAdapt_Browser_Session { public const TYP = 'poppy-browser+jwt'; public const PREFIX = 'agc_bs_'; /** * The cookie. Named under WooCommerce's session prefix on purpose: page * caches and CDNs already bypass requests carrying `wp_woocommerce_session_*`, * so an agent's browser never gets a cached page meant for someone else. * (WooCommerce's own cookie is `wp_woocommerce_session_`, so * the names never collide.) */ public const COOKIE = 'wp_woocommerce_session_agentadapt'; /** * Longest browser assertion lifetime (spec: exp at most 60 s after iat). */ private const MAX_LIFETIME = 60; /** * Endpoint URL (the assertion's aud). */ public static function endpoint(): string { return AgentAdapt_Urls::url( '/' . AgentAdapt_Urls::BASE . '/browser-session' ); } /** * POST /agent-adapt/browser-session */ public static function handle(): void { if ( 'POST' !== AgentAdapt_Http::method() ) { AgentAdapt_Pages::send( 405, __( 'Not available', 'agent-adapt' ), '' ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence check: an assertion in the URL is refused outright. if ( isset( $_GET['assertion'] ) ) { self::fail( 'the assertion must be in the request body, never the URL' ); } // The signed, single-use assertion is the CSRF defence here. $assertion = isset( $_POST['assertion'] ) ? (string) wp_unslash( $_POST['assertion'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a JWT, verified below and never output. $peek = AgentAdapt_Jose::peek( $assertion ); if ( null === $peek ) { self::fail( 'missing or malformed assertion' ); } $agent = AgentAdapt_Agents::resolve( (string) ( $peek[1]['iss'] ?? '' ) ); if ( is_wp_error( $agent ) ) { if ( 'rate_limited' === $agent->get_error_code() ) { AgentAdapt_Http::rate_limited( 3600 ); } self::fail( 'unknown agent' ); } $c = AgentAdapt_Client_Auth::verify_agent_jwt( $agent, $assertion, 'browser', self::TYP ); if ( is_wp_error( $c ) ) { self::fail( $c->get_error_message() ); } if ( ! is_string( $c['aud'] ?? null ) || AgentAdapt_Urls::normalise( $c['aud'] ) !== AgentAdapt_Urls::normalise( self::endpoint() ) ) { self::fail( 'aud must be this endpoint' ); } if ( (int) $c['exp'] - (int) $c['iat'] > self::MAX_LIFETIME ) { self::fail( 'a browser assertion may live at most 60 seconds' ); } $sub = is_string( $c['sub'] ?? null ) ? $c['sub'] : ''; $session = AgentAdapt_Sessions::get( (string) ( $c['session_id'] ?? '' ) ); if ( '' === $sub || ! $session || $session['client_hash'] !== $agent['client_hash'] || (int) $session['subject_id'] !== AgentAdapt_Sessions::subject_id( $agent['client_id'], $sub ) ) { self::fail( 'the Session is not live, or not this agent\'s and User\'s' ); } $return_to = is_string( $c['return_to'] ?? null ) ? $c['return_to'] : ''; if ( ! AgentAdapt_Urls::on_own_domain( $return_to ) ) { self::fail( 'return_to must be an https URL on this store' ); } // Pass: our own cookie, tied to the Session server-side. global $wpdb; $token = self::PREFIX . AgentAdapt_Tokens::random( 32 ); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery AgentAdapt_Schema::table( 'tokens' ), array( 'token_hash' => hash( 'sha256', $token ), 'session_id' => $session['session_id'], 'kind' => 'browser', 'scope' => '', 'expires' => (int) $session['expires'] ) ); self::set_cookie( $token ); AgentAdapt_Events::record( 'web.joined', null, array( 'client_hash' => $agent['client_hash'], 'subject_id' => (int) $session['subject_id'], 'session_id' => $session['session_id'], 'channel' => 'web' ) ); nocache_headers(); // return_to was just checked to be on this store's own domain. wp_redirect( $return_to, 303, 'Agent Adapt' ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect exit; } /** * Set the Session cookie: Secure, HttpOnly, SameSite=Lax (spec: not * Strict, or the browser would drop it on the 303 from the agent's page), * host-only (no Domain: limited to this store's host, Guides), and a * browser-session cookie whose validity the server re-checks on every * request, so it can never outlive the Session. * * @param string $token Cookie value. */ public static function set_cookie( string $token ): void { if ( headers_sent() ) { return; } setcookie( self::COOKIE, $token, array( 'expires' => 0, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ) ); } /** * Expire the cookie (its Session ended, or its agent was blocked). */ public static function clear_cookie(): void { if ( headers_sent() ) { return; } setcookie( self::COOKIE, '', array( 'expires' => time() - YEAR_IN_SECONDS, 'path' => COOKIEPATH ? COOKIEPATH : '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax' ) ); } /** * Refuse: HTTP 400, no cookie. * * @param string $why Reason (shown to the agent's browser, no account data). */ private static function fail( string $why ): void { AgentAdapt_Pages::send( 400, __( 'This link can\'t be used', 'agent-adapt' ), '

' . esc_html( $why ) . '

' ); } }