hash( 'sha256', $code ), 'kind' => 'auth', 'client_hash' => $client_hash, 'user_id' => $user_id, 'granted_scope' => $granted_scope, 'redirect_uri' => $redirect_uri, 'code_challenge' => $code_challenge, 'resource' => $resource, 'status' => 'approved', 'expires' => time() + self::AUTH_TTL, ) ); return $code; } /** * Use an authorization code. Any presentation by the right client burns * it; a second presentation revokes what the first produced. * * @param string $code Presented code. * @param string $client_hash Authenticated agent. * @return array|WP_Error The code row on success. */ public static function consume_auth( string $code, string $client_hash ) { $row = self::row( hash( 'sha256', $code ), 'auth' ); if ( ! $row || ! hash_equals( (string) $row['client_hash'], $client_hash ) ) { return new WP_Error( 'invalid_grant', 'unknown authorization code' ); } if ( ! self::transition( (string) $row['code_hash'], 'approved', 'used' ) ) { if ( 'used' === $row['status'] && '' !== $row['grant_id'] ) { AgentAdapt_Grants::revoke( (string) $row['grant_id'], 'code_reused' ); } return new WP_Error( 'invalid_grant', 'this authorization code was already used' ); } if ( (int) $row['expires'] < time() ) { return new WP_Error( 'invalid_grant', 'this authorization code has expired' ); } return $row; } /** * Remember which grant a code produced (for reuse revocation). * * @param string $code_hash Code hash. * @param string $grant_id Grant. */ public static function link_grant( string $code_hash, string $grant_id ): void { global $wpdb; $wpdb->update( AgentAdapt_Schema::table( 'codes' ), array( 'grant_id' => $grant_id ), array( 'code_hash' => $code_hash ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Start a Device Sign-In. * * @param string $client_hash Agent. * @param string $scope Requested scopes. * @return array{device_code: string, user_code: string} */ public static function create_device( string $client_hash, string $scope ): array { $device = 'agc_dc_' . AgentAdapt_Tokens::random( 32 ); do { $user_code = self::user_code(); } while ( self::device_by_user_code( $user_code ) ); self::insert( array( 'code_hash' => hash( 'sha256', $device ), 'kind' => 'device', 'client_hash' => $client_hash, 'scope' => $scope, 'user_code' => $user_code, 'status' => 'pending', 'expires' => time() + self::DEVICE_TTL, 'poll_interval' => self::DEVICE_POLL, ) ); return array( 'device_code' => $device, 'user_code' => self::format( $user_code ) ); } /** * A pending, live device request by the code the customer typed. * * @param string $typed User code, any formatting. * @return array|null */ public static function device_by_user_code( string $typed ): ?array { global $wpdb; $norm = self::normalise( $typed ); if ( 8 !== strlen( $norm ) ) { return null; } $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE kind = 'device' AND user_code = %s AND status = 'pending' AND expires >= %d", AgentAdapt_Schema::table( 'codes' ), $norm, time() ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return $row ?: null; } /** * The customer approved or declined on the verification page. * * @param string $code_hash Device code hash. * @param bool $approve Approved? * @param int $user_id Account. * @param string $granted_scope Scopes approved. */ public static function decide_device( string $code_hash, bool $approve, int $user_id, string $granted_scope ): bool { global $wpdb; $rows = $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery AgentAdapt_Schema::table( 'codes' ), array( 'status' => $approve ? 'approved' : 'denied', 'user_id' => $approve ? $user_id : 0, 'granted_scope' => $approve ? $granted_scope : '' ), array( 'code_hash' => $code_hash, 'status' => 'pending' ) ); return 1 === (int) $rows; } /** * The agent polls (spec 4.6). * * @param string $device_code Presented device code. * @param string $client_hash Authenticated agent. * @return array|WP_Error Approved row, or authorization_pending / slow_down / access_denied / expired_token / invalid_grant. */ public static function poll_device( string $device_code, string $client_hash ) { global $wpdb; $hash = hash( 'sha256', $device_code ); $row = self::row( $hash, 'device' ); if ( ! $row || ! hash_equals( (string) $row['client_hash'], $client_hash ) ) { return new WP_Error( 'invalid_grant', 'unknown device code' ); } $now = time(); if ( (int) $row['expires'] < $now && 'used' !== $row['status'] ) { return new WP_Error( 'expired_token', 'the sign-in request expired' ); } if ( 'denied' === $row['status'] ) { return new WP_Error( 'access_denied', 'the customer declined' ); } if ( 'used' === $row['status'] ) { return new WP_Error( 'invalid_grant', 'this device code was already used' ); } $interval = (int) $row['poll_interval']; $wpdb->update( AgentAdapt_Schema::table( 'codes' ), array( 'last_poll' => $now ), array( 'code_hash' => $hash ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( 'pending' === $row['status'] ) { if ( (int) $row['last_poll'] > 0 && $now - (int) $row['last_poll'] < $interval ) { // RFC 8628 §3.5: slow down, and the interval grows by 5 s. $wpdb->update( AgentAdapt_Schema::table( 'codes' ), array( 'poll_interval' => $interval + 5 ), array( 'code_hash' => $hash ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return new WP_Error( 'slow_down', 'poll less often' ); } return new WP_Error( 'authorization_pending', 'the customer has not finished yet' ); } if ( ! self::transition( $hash, 'approved', 'used' ) ) { return new WP_Error( 'invalid_grant', 'this device code was already used' ); } return $row; } /** * Atomically move a code from one status to another. * * @param string $code_hash Code hash. * @param string $from Expected status. * @param string $to New status. */ private static function transition( string $code_hash, string $from, string $to ): bool { global $wpdb; return 1 === (int) $wpdb->update( AgentAdapt_Schema::table( 'codes' ), array( 'status' => $to ), array( 'code_hash' => $code_hash, 'status' => $from ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Row by hash and kind. * * @param string $code_hash Hash. * @param string $kind auth|device. * @return array|null */ private static function row( string $code_hash, string $kind ): ?array { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE code_hash = %s AND kind = %s', AgentAdapt_Schema::table( 'codes' ), $code_hash, $kind ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return $row ?: null; } /** * Insert a code row. * * @param array $row Row. */ private static function insert( array $row ): void { global $wpdb; $wpdb->insert( AgentAdapt_Schema::table( 'codes' ), $row + array( 'created' => time() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Eight characters from the alphabet, random. */ private static function user_code(): string { $out = ''; for ( $i = 0; $i < 8; $i++ ) { $out .= self::USER_ALPHABET[ random_int( 0, strlen( self::USER_ALPHABET ) - 1 ) ]; } return $out; } /** * Upper-case, alphabet letters only. * * @param string $typed Input. */ public static function normalise( string $typed ): string { return (string) preg_replace( '/[^' . self::USER_ALPHABET . ']/', '', strtoupper( $typed ) ); } /** * XXXX-XXXX for display. * * @param string $norm Normalised code. */ public static function format( string $norm ): string { return substr( $norm, 0, 4 ) . '-' . substr( $norm, 4 ); } /** * Delete every code an account approved (WordPress's personal-data * eraser), whether or not the agent has used it, so none can still become * a connection. A code no one has approved yet names no account. * * @param int $user_id Account. * @return int Codes deleted. */ public static function delete_for_user( int $user_id ): int { if ( $user_id < 1 ) { return 0; } global $wpdb; return (int) $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE user_id = %d', AgentAdapt_Schema::table( 'codes' ), $user_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * Delete codes a day past expiry. */ public static function purge(): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires < %d', AgentAdapt_Schema::table( 'codes' ), time() - DAY_IN_SECONDS ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } }