tokens = $tokens; } /** * Whether a Google account is currently connected: a refresh token is * stored AND it still decrypts. After a salt or key change the stored * token is unreadable, so the site is not connected (see * reconnect_required()). */ public function is_connected(): bool { return null !== $this->refresh_token(); } /** * Whether a refresh token is stored but can no longer be decrypted, * typically because the site's security keys (AUTH_KEY / * SECURE_AUTH_SALT) changed. The user must reconnect Google. */ public function reconnect_required(): bool { return '' !== (string) $this->data()['refresh_token'] && null === $this->refresh_token(); } /** * The connection mode: 'relay' or 'byo'. */ public function mode(): string { return (string) $this->data()['mode']; } /** * Saves a fresh token grant, merging with any existing connection state. * * @param array $t Token grant. Keys: refresh_token? (may be * empty to keep the previous one), access_token, * expires_in, scope, email, mode, connected_by. * @throws \InvalidArgumentException When access_token is empty. */ public function save_tokens( array $t ): void { $access_token = (string) ( $t['access_token'] ?? '' ); if ( '' === $access_token ) { throw new \InvalidArgumentException( 'access_token must not be empty.' ); } $data = $this->data(); $new_refresh_token = (string) ( $t['refresh_token'] ?? '' ); $incoming_scopes = array_values( array_filter( explode( ' ', (string) ( $t['scope'] ?? '' ) ) ) ); if ( '' !== $new_refresh_token ) { $data['refresh_token'] = $this->tokens->encrypt( $new_refresh_token ); // A new grant replaces the scopes: Google returns the full granted // set (include_granted_scopes), and the grant may be for another // account or a narrower set. $data['scopes'] = $incoming_scopes; } else { // Same-mode reconnect that kept the stored refresh token: merge. $data['scopes'] = array_merge( $data['scopes'], $incoming_scopes ); } $data['scopes'] = array_values( array_unique( $data['scopes'] ) ); sort( $data['scopes'] ); $data['mode'] = (string) ( $t['mode'] ?? $data['mode'] ); $data['email'] = (string) ( $t['email'] ?? $data['email'] ); $data['connected_by'] = isset( $t['connected_by'] ) ? (int) $t['connected_by'] : $data['connected_by']; $data['connected_at'] = time(); $data['home_url'] = untrailingslashit( home_url() ); $this->save( $data ); $this->set_access_token( $access_token, (int) ( $t['expires_in'] ?? 0 ) ); } /** * The plain refresh token, or null when not connected or undecryptable. */ public function refresh_token(): ?string { $stored = (string) $this->data()['refresh_token']; if ( '' === $stored ) { return null; } $plain = $this->tokens->decrypt( $stored ); return null === $plain || '' === $plain ? null : $plain; } /** * The plain current access token, or null when absent, expired or undecryptable. */ public function access_token(): ?string { $stored = get_transient( self::ACCESS_TRANSIENT ); if ( ! is_string( $stored ) || '' === $stored ) { return null; } return $this->tokens->decrypt( $stored ); } /** * Encrypts and stores an access token in the short-lived transient. * * @param string $token Plain access token. * @param int $expires_in Seconds until the token expires, per Google. */ public function set_access_token( #[\SensitiveParameter] string $token, int $expires_in ): void { $ttl = max( 60, $expires_in - 300 ); set_transient( self::ACCESS_TRANSIENT, $this->tokens->encrypt( $token ), $ttl ); } /** * The granted scopes (full scope URLs / OpenID scope names). * * @return string[] */ public function scopes(): array { return $this->data()['scopes']; } /** * Whether a given scope has been granted. * * @param string $scope Full scope URL or name. */ public function has_scope( string $scope ): bool { return in_array( $scope, $this->scopes(), true ); } /** * The email address of the connected Google account. */ public function email(): string { return (string) $this->data()['email']; } /** * Whether the site's current home_url() differs from the one stored at * connect time. Always false when not connected. */ public function home_url_changed(): bool { if ( ! $this->is_connected() ) { return false; } return untrailingslashit( home_url() ) !== (string) $this->data()['home_url']; } /** * Accepts the current home_url() as correct, clearing home_url_changed(). */ public function accept_home_url(): void { $data = $this->data(); $data['home_url'] = untrailingslashit( home_url() ); $this->save( $data ); } /** * The BYO OAuth client credentials, or null when none are stored. * * @return array{client_id: string, client_secret: ?string}|null */ public function byo_credentials(): ?array { $data = $this->data(); if ( null === $data['byo_client_id'] ) { return null; } $secret = $data['byo_client_secret']; return array( 'client_id' => $data['byo_client_id'], 'client_secret' => null === $secret ? null : $this->tokens->decrypt( $secret ), ); } /** * Sets or clears the BYO OAuth client credentials. * * @param string|null $client_id Client id, plain (not secret). Null removes both values. * @param string|null $client_secret Client secret, plain. Encrypted at rest. */ public function set_byo_credentials( ?string $client_id, ?string $client_secret ): void { $data = $this->data(); $data['byo_client_id'] = $client_id; $data['byo_client_secret'] = ( null === $client_secret || '' === $client_secret ) ? null : $this->tokens->encrypt( $client_secret ); $this->save( $data ); } /** * A safe-to-display summary of the connection state. Never includes secrets. * * `reconnect_required` is true when a refresh token is stored but no * longer decrypts (the site's security keys changed); `connected` is * then false. * * @return array{connected: bool, reconnect_required: bool, mode: string, email: string, scopes: string[], connected_at: ?int, home_url_changed: bool, has_byo_credentials: bool} */ public function summary(): array { $data = $this->data(); return array( 'connected' => $this->is_connected(), 'reconnect_required' => $this->reconnect_required(), 'mode' => $this->mode(), 'email' => $this->email(), 'scopes' => $this->scopes(), 'connected_at' => null === $data['connected_at'] ? null : (int) $data['connected_at'], 'home_url_changed' => $this->home_url_changed(), 'has_byo_credentials' => null !== $data['byo_client_id'], ); } /** * Disconnects: removes the tokens and access transient, keeps BYO credentials. */ public function clear(): void { $data = $this->data(); $data['mode'] = ''; $data['email'] = ''; $data['scopes'] = array(); $data['connected_by'] = null; $data['connected_at'] = null; $data['home_url'] = ''; $data['refresh_token'] = ''; $this->save( $data ); delete_transient( self::ACCESS_TRANSIENT ); } /** * Disconnects and also removes any stored BYO OAuth credentials. */ public function forget_all(): void { $this->clear(); $data = $this->data(); $data['byo_client_id'] = null; $data['byo_client_secret'] = null; $this->save( $data ); } /** * The current connection option, merged with defaults for missing keys. * * @return array */ private function data(): array { $stored = get_option( self::OPTION, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return array_merge( array( 'mode' => '', 'email' => '', 'scopes' => array(), 'connected_by' => null, 'connected_at' => null, 'home_url' => '', 'refresh_token' => '', 'byo_client_id' => null, 'byo_client_secret' => null, ), $stored ); } /** * Persists the connection option with autoload disabled. * * @param array $data Full connection data to store. */ private function save( array $data ): void { update_option( self::OPTION, $data, false ); } }