PHP Zoom API JWT Bit Banging

It’s always hard to nail the exact sequence when authorizing through a new API. Here’s what I came up with for PHP authorization with Zoom’s JWTs. This is essentially a quick start which gets you enough functions to do a first API call: to list zoom users. Line 40->68 is where the JWt meat happens.

<?php

// config parameters you need to define
define( 'ZOOM_TOKEN_FILE', "/var/.zoom_token" ) ; // some location on the filesystem used to cache your token while it's current (make sure permissions are restrictive)
define( 'ZOOM_API_KEY', "" ) ;
define( 'ZOOM_API_SECRET', "" ) ;


// main
print_r( zoom_list_users() ) ;
exit( 0 ) ;


// functions
function zoom_list_users() {
    $users = array() ;

    $page_number = 1 ;
    $keep_going = true ;
    while( $keep_going && $page_number<10000 ) {
        $result = zoom_make_api_call( "GET", "https://api.zoom.us/v2/users", array('page_size'=>300, 'page_number'=>$page_number, 'status'=>"active") ) ;
        $result = json_decode( $result, true ) ;
        if( array_key_exists('users', $result) &&
            count($result['users'])>0 ) {
            foreach( $result['users'] as $user ) {
                $users[] = $user ;
            }
            $page_number++ ;
            if( $page_number>$result['page_count'] ) {
                $keep_going = false ;
            }
        } else {
            $keep_going = false ;
        }
    }

    return $users ;
}

// PHP's default base64 encode isn't URL safe which messes up the JWT, we need these functions instead
function base64_url_encode( $data ) {
    return rtrim( strtr(base64_encode($data), '+/', '-_'), '=' ) ;
}
function base64_url_decode( $data ) {
    return base64_decode( str_pad(strtr($data, '-_', '+/'), strlen($data) % 4, '=', STR_PAD_RIGHT) ) ;
}

function get_token( $refresh=false ) {
    if( $refresh===false &&
        file_exists(ZOOM_TOKEN_FILE) ) {
      	return file_get_contents( ZOOM_TOKEN_FILE ) ;
    }

    $jwt_request_date    = @date( "U" ) ; // no warning, proper system timezone assumed
    $jwt_expiration_date = $jwt_request_date + 60*60 ; # +1 hour
    $jwt_header          = '{"alg":"HS256","typ":"JWT"}' ;
    $jwt_claim_set       = '{"iss":"' . ZOOM_API_KEY . '","exp":' . $jwt_expiration_date . '}' ;
    $jwt_signature       = sign_data( base64_url_encode($jwt_header) . '.' . base64_url_encode($jwt_claim_set), ZOOM_API_SECRET ) ;
    $jwt                 = base64_url_encode( $jwt_header ) . "." . base64_url_encode( $jwt_claim_set ) . "." . base64_url_encode( $jwt_signature ) ;

    file_put_contents( ZOOM_TOKEN_FILE, $jwt ) ;
    return $jwt ;
}


function sign_data( $data, $key ) {
    return hash_hmac( "SHA256" , $data, $key, true) ;
}


function zoom_make_api_call( $request, $url, $get_variables=null, $post_variables=null, $force_refresh_token=false ) {
    $ch = curl_init() ;
    $token = get_token( $force_refresh_token ) ;

    $getfields = "" ;
    if( $get_variables!==null && is_array($get_variables) ) {
        foreach( $get_variables as $get_variable_key=>$get_variable_value ) {
            $getfields .= "&" . urlencode( $get_variable_key ) . "=" . urlencode( $get_variable_value ) ;
        }
        if( strlen($getfields)>0 ) {
            $getfields = "?" . substr( $getfields, 1 ) ;
        }
    }

    curl_setopt( $ch, CURLOPT_URL, "{$url}{$getfields}" ) ;
    curl_setopt( $ch, CURLOPT_PORT , 443 ) ;
    curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, $request ) ;
    curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, false ) ;
    if( $post_variables!==null && is_array($post_variables) ) {
        $postfields = "" ;
        foreach( $post_variables as $post_variable_key=>$post_variable_value ) {
            $postfields .= "&" . urlencode( $post_variable_key ) . "=" . urlencode( $post_variable_value ) ;
        }
        $postfields = substr( $postfields, 1 ) ;
        curl_setopt( $ch, CURLOPT_POSTFIELDS, $postfields ) ;
    } else if( $post_variables!==null && is_string($post_variables) ) {
        curl_setopt( $ch, CURLOPT_POSTFIELDS, $post_variables ) ;
    }
    curl_setopt( $ch, CURLOPT_HTTPHEADER, array( "authorization: Bearer {$token}",
                                                 "content-type: application/json") ) ;

    curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ) ;
    curl_setopt( $ch, CURLOPT_HEADER, true ) ;

    $response = curl_exec( $ch ) ;
    curl_close( $ch ) ;

    $response = parse_http_response( $response ) ;

    if( $response['code']=="200" ||
        $response['code']=="204" ||
        $response['code']=="404" ) {
        return $response['body'] ;
    } else if( $response['code']=="401" ) { // expired token
        if( $force_refresh_token===false ) {
            // safe to recurse
            return zoom_make_api_call( $request, $url, $get_variables, $post_variables, true ) ;
        } else {
            echo "ERROR: had an expired token and I tried to refresh it, yet somehow it's still expired\n" ;
            print_r( $response ) ;
            exit( 1 ) ;
        }
    } else {
        echo "ERROR: I have no idea what to do with this response from Zoom\n" ;
        print_r( $response ) ;
        exit( 1 ) ;
    }
}


function parse_http_response( $raw_data ) {
    $parsed_response = array( 'code'=>-1, 'headers'=>array(), 'body'=>"" ) ;

    $raw_data = explode( "\r\n", $raw_data ) ;

    $parsed_response['code'] = explode( " ", $raw_data[0] ) ;
    $parsed_response['code'] = $parsed_response['code'][1] ;
    $i = 1 ;
    if( $parsed_response['code']=="100" ) {
        $parsed_response['code'] = explode( " ", $raw_data[2] ) ;
        $parsed_response['code'] = $parsed_response['code'][1] ;
        $i = 3 ;
    }

    for( ; $i<count($raw_data) ; $i++ ) {
        $raw_datum = $raw_data[$i] ;

        $raw_datum = trim( $raw_datum ) ;
        if( $raw_datum!="" ) {
            if( substr_count($raw_datum, ':')>=1 ) {
                $raw_datum = explode( ':', $raw_datum, 2 ) ;
                $parsed_response['headers'][strtolower($raw_datum[0])] = trim( $raw_datum[1] ) ;
            }  else {
                echo "ERROR: we're in the headers section of parsing an HTTP section and no colon was found for line: {$raw_datum}\n" ;
                exit( 1 ) ;
            }
        } else {
            // we've moved to the body section
            if( ($i+1)<count($raw_data) ) {
                for( $j=($i+1) ; $j<count($raw_data) ; $j++ ) {
                    $parsed_response['body'] .= $raw_data[$j] . "\n" ;
                }
            }

            // we don't need to continue the $i loop
            break ;
        }
    }

    return $parsed_response ;
}

?>