$extra Provider-specific parameters (`access_type`, `prompt`). */ public function authorize_url( string $authorize_url, string $client_id, string $redirect_uri, string $scope, string $state, array $extra = array() ): string { $params = array_merge( array( 'response_type' => 'code', 'client_id' => $client_id, 'redirect_uri' => $redirect_uri, 'scope' => $scope, 'state' => $state, ), $extra ); return add_query_arg( array_map( 'rawurlencode', $params ), $authorize_url ); } /** * Trade an authorization code for tokens. * * @param string $token_url The provider's token endpoint. * @param string $client_id The site's own client id. * @param string $client_secret The site's own client secret. * @param string $code What came back on the redirect. * @param string $redirect_uri The same one used to start the flow. * * @return array|WP_Error The token response, with `expires_at` added. */ public function exchange_code( string $token_url, string $client_id, string $client_secret, string $code, string $redirect_uri ): array|WP_Error { return $this->token_request( $token_url, array( 'grant_type' => 'authorization_code', 'code' => $code, 'client_id' => $client_id, 'client_secret' => $client_secret, 'redirect_uri' => $redirect_uri, ) ); } /** * Get a fresh access token. * * A REFRESH RESPONSE OFTEN OMITS THE REFRESH TOKEN, and treating that as "the token is now empty" * is the classic way to break an integration a month after it was set up. Google returns a * refresh token only on the first authorization; every refresh after that returns an access token * alone, and a caller that overwrites its stored credentials with the response wholesale loses * the only thing that could have refreshed again. {@see merge_tokens()} is the safe way to apply * this, and it is why that helper exists rather than leaving callers to `array_merge` by hand. * * @param string $token_url The provider's token endpoint. * @param string $client_id The site's own client id. * @param string $client_secret The site's own client secret. * @param string $refresh_token The stored refresh token. * * @return array|WP_Error */ public function refresh( string $token_url, string $client_id, string $client_secret, string $refresh_token ): array|WP_Error { return $this->token_request( $token_url, array( 'grant_type' => 'refresh_token', 'refresh_token' => $refresh_token, 'client_id' => $client_id, 'client_secret' => $client_secret, ) ); } /** * Apply a token response over what is stored, without losing the refresh token. * * @param array $stored What {@see Credentials::get()} returned. * @param array $response What {@see refresh()} or {@see exchange_code()} returned. * * @return array Ready to hand back to {@see Credentials::put()}. */ public function merge_tokens( array $stored, array $response ): array { $merged = $stored; foreach ( array( 'access_token', 'refresh_token', 'expires_at', 'token_type', 'scope' ) as $key ) { // An absent OR EMPTY value leaves the stored one alone. Providers differ in which they do, // and both mean the same thing: "unchanged", never "cleared". if ( isset( $response[ $key ] ) && '' !== (string) $response[ $key ] ) { $merged[ $key ] = (string) $response[ $key ]; } } return $merged; } /** * Whether a stored access token is past, or nearly past, its life. * * THE SKEW IS THE USEFUL PART. Asking "has it expired?" gets a token that expires during the very * request it was fetched for — a slow API call is easily thirty seconds — and the failure looks * like an intermittent 401 rather than an expiry. Sixty seconds of margin turns that into a * refresh. * * @param array $stored What {@see Credentials::get()} returned. * @param int $skew Seconds of margin. */ public function needs_refresh( array $stored, int $skew = 60 ): bool { $expires = (int) ( $stored['expires_at'] ?? 0 ); // No expiry recorded: a token that never claimed a lifetime is treated as still good. Refreshing // on every call instead would hammer the provider for a token it never said would expire. if ( $expires <= 0 ) { return false; } return ( $expires - $skew ) <= time(); } /** * POST to a token endpoint and make sense of the answer. * * @param string $token_url The endpoint. * @param array $body Form fields. * * @return array|WP_Error */ private function token_request( string $token_url, array $body ): array|WP_Error { $response = $this->http->post( $token_url, http_build_query( $body ), array( 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json', ) ); if ( $response instanceof WP_Error ) { return $response; } $data = json_decode( $response['body'], true ); if ( ! is_array( $data ) ) { return new WP_Error( 'plugixa_crm_oauth_bad_response', __( 'The service did not return a usable response. Check the token URL.', 'plugixa-crm' ) ); } if ( $response['status'] < 200 || $response['status'] >= 300 ) { /* * The provider's own `error_description` is repeated verbatim, and that is a deliberate * choice rather than laziness. OAuth failures are nearly always a mismatch between what * was typed here and what is registered there — a redirect URI with a trailing slash, a * scope not enabled on the project — and the provider is the only party that knows which. * Replacing it with "Could not connect" throws away the one sentence that would have * fixed it. The route that surfaces this is admin-only. */ $detail = (string) ( $data['error_description'] ?? $data['error'] ?? '' ); return new WP_Error( 'plugixa_crm_oauth_refused', '' !== $detail ? $detail : __( 'The service refused the connection.', 'plugixa-crm' ) ); } // expires_in is a DURATION and is useless once stored — "3600" written yesterday still reads as // an hour from now. Converting to an absolute timestamp at the moment of receipt is the only // point at which the arithmetic is correct. if ( isset( $data['expires_in'] ) && is_numeric( $data['expires_in'] ) ) { $data['expires_at'] = time() + (int) $data['expires_in']; } return $data; } }