transientName() ); if ( is_string( $cached ) && '' !== $cached ) { return $cached; } return $this->fetch()['token']; } /** Drops the cached token, so the next token() call asks Canada Post. */ public function forget(): void { delete_transient( $this->transientName() ); } /** * Asks for a new token even if one is cached, and returns how long * Canada Post says it lasts, in seconds. Used by "Test connection". */ public function refresh(): int { $this->forget(); return $this->fetch()['lifetime']; } public function transientName(): string { return 'bbmmp_token_' . $this->credentials->fingerprint(); } /** * How long to cache a token that lasts $expiresIn seconds: up to five * minutes less, and never more than four fifths of its life. */ public static function cacheSeconds( int $expiresIn ): int { if ( $expiresIn <= 0 ) { $expiresIn = self::FALLBACK_LIFETIME; } return max( 1, $expiresIn - min( 300, intdiv( $expiresIn, 5 ) ) ); } /** * @return array{token: string, lifetime: int} * @throws ApiException When there is no key or secret, or Canada Post gives no token. */ private function fetch(): array { if ( ! $this->credentials->isComplete() ) { throw new AuthException( __( 'The Canada Post key or secret is missing. Enter both in WooCommerce > Settings > Shipping > Maple Parcel.', 'maple-parcel' ) ); } $response = wp_remote_post( self::URL, array( 'timeout' => self::TIMEOUT, 'headers' => array( 'X-IBM-Client-Id' => $this->credentials->clientId, 'X-IBM-Client-Secret' => $this->credentials->secret(), 'Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded', ), 'body' => array( 'scope' => 'merchant', 'grant_type' => 'client_credentials', ), ) ); if ( is_wp_error( $response ) ) { throw new TransportException( /* translators: %s: the HTTP error, e.g. a timeout. */ sprintf( __( 'Could not reach Canada Post to sign in: %s', 'maple-parcel' ), $response->get_error_message() ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); $data = json_decode( $raw, true ); $this->logger->debug( sprintf( 'Token request: HTTP %d (%s app).', $status, $this->credentials->environment ) ); if ( Client::isRateLimited( $status, $raw ) ) { Client::startBackOff(); throw new RateLimitedException( __( 'Canada Post is limiting requests from this store. Rates will be tried again in a minute.', 'maple-parcel' ), $status ); } if ( $status >= 400 && $status < 500 ) { // Canada Post's own reason, e.g. "Invalid client id or secret." Its error bodies never echo the credentials. [ $reason ] = Client::describeErrors( $data ); throw new AuthException( sprintf( /* translators: 1: HTTP status code, 2: Canada Post's reason, or nothing. */ __( 'Canada Post did not accept the key and secret (HTTP %1$d%2$s). Check that both were copied from the same app in the Developer Portal, and that Environment matches that app. A username and password from Canada Post\'s old developer program will not work here: you need an app from the new Developer Portal.', 'maple-parcel' ), $status, '' !== $reason ? ': ' . $reason : '' ), $status ); } $token = is_array( $data ) && isset( $data['access_token'] ) && is_string( $data['access_token'] ) ? $data['access_token'] : ''; if ( 200 !== $status || '' === $token ) { throw new TransportException( /* translators: %d: HTTP status code. */ sprintf( __( 'Canada Post did not return a sign-in token (HTTP %d). It may be down; rates will be tried again shortly.', 'maple-parcel' ), $status ), $status ); } $lifetime = is_array( $data ) && is_numeric( $data['expires_in'] ?? null ) ? (int) $data['expires_in'] : 0; set_transient( $this->transientName(), $token, self::cacheSeconds( $lifetime ) ); return array( 'token' => $token, 'lifetime' => $lifetime, ); } }