Jamf Pro API for fun and profit

Profit maybe, fun not so much.

New and shiny appears nice but there are pitfalls!

Jamf Pro has two API’s that are accessible for administrators, the “Classic” and the “Jamf Pro”. All of Jamf’s love and attention is focussed on the newer “Jamf Pro” one and while at the time of writing the “Classic” still works, I’ve noticed it becoming a touch flaky over time.

There were always issues such as having to explicitly specify the type of output in the curl command because different installed Java on the server behaved differently. Oracle Java would give you xml every time, OpenJDK gave JSON and don’t even ask about Corretto…

curl -X GET "https://corp.jamfcloud.com/JSSResource/computers" -H "accept: application/xml"

Note the -H in the command.

To solve this and other issues, Jamf has been busy building a newer API on a more modern underpinning. You can find more in depth details here https://developer.jamf.com/jamf-pro/reference/jamf-pro-api but you can also find a sandbox environment on your own Jamf Pro server by using /api at the end of the URL.

Now this comes with some big caveats and I’m going to list the ones I’ve found here:

  • Basic authentication
    • The old method of submitting a username and password is horrifically insecure so a newer method with access tokens is in place.
  • Endpoint change
    • All the API endpoints have changed names, changed behaviors and in some cases disappeared altogether
  • JSON only
    • You now have to work entirely with JSON input and output.

In order, dealing with a lack of Basic auth is awkward but not insurmountable. The endpoint changes mostly result in methods having to be rethought and reworked but the JSON only? AARGH.

The full move to JSON is pitched at using a platform agnostic type standard. That’s all well and good but macOS doesn’t really have native tools for JSON. (We’ll come back to this).

But aha I hear you cry! What about perl? What about Python? True these do have support but … they’re going away from macOS and you get warnings trying to use them in macOS Monterey.

What about jq? Oh great, having to obtain and mass deploy a CLI tool to use infrequently and then deal with it’s upkeep. No I didn’t sign up for that.

This is where we have to stand on the shoulders of giants. Matthew Warren has discovered a way of doing JSON with native tools, it’s just the tool is JavaScript. I would highly recommend reading this https://www.macblog.org/posts/how-to-parse-json-macos-command-line/ first.

Now let’s start.

As mentioned above, the original method was to supply a username and password with your curl command to authenticate.

curl -u username:password -X GET "https://therealreal.jamfcloud.com/JSSResource/computers" -H "accept: application/xml"

What we now have to do is this:

  • base64 encode your existing API credentials
  • Feed that to a specific API endpoint to obtain a bearer token
  • Separate the token from the rest of the output
  • Use the token for the operations you need
  • Invalidate the token when complete

Quick note: the tokens have an expiry but it’s just good practice to invalidate them after use especially if your operations are quick. As an added security measure, your access credentials in Jamf ARE scoped only to the task you’re performing right? 😉

Let’s start with doing a base64 encode of your credentials. In this example I’ll be using username and password for … username and password.

[email protected]yMac ~ % printf "username:password" | iconv -t ISO-8859-1 | base64 -i -
dXNlcm5hbWU6cGFzc3dvcmQ=

Ok we have our encoded password. Next step will be to use it to get a bearer token for our use. I’m going to put in some extra code to find our Jamf Pro address too. Finally we’ll use the tr command to remove ALL the line feeds from the output.

# Current JSS address
jss_url=$( /usr/bin/defaults read /Library/Preferences/com.jamfsoftware.jamf.plist jss_url )

jsonresponse=$( /usr/bin/curl -s "${jss_url}api/v1/auth/token" -H "authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=" -X POST | tr -d "\n" )

That gets us something that looks like this:

[email protected] ~ % echo $jsonresponse 
{  "token" : "eyJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkLWFwcCI6IkdFTkVSSUMiLCJhdXRoZW50aWNhdGlvbi10eXBlIjoiSlNTIiwiZ3JvdXBzIjpbXSwic3ViamVjdC10eXBlIjoiSlNTX1VTRVJfSUQiLCJ0b2tlbi11dWlkIjoiYWZlNmQyODItOGY5Ni00YTRiLThkNDEtNmI0ZTc2YzliOTZjIiwibGRhcC1zZXJ2ZXItaWQiOi0xLCJzdWIiOiIxMjIiLCJleHAiOjE2MzkwNzI0MDB9.sa7_o2t69kbtEKtWMQqK2zGQSX3NqOsAUKggI1aGulQ",  "expires" : "2021-12-09T17:53:20.385Z"}

We have our token. As you can see it has a timer on it and by the time I’ve posted this blog post, the above will have nicely expired 😉 However what we need to do is to separate it out from this JSON mess and that’s where Matthew’s blog post above explains how to do.

Here’s my rather shortened version. We take the contents of that variable, feed it into the JavaScript parser in the osascript command, use the JSON.parse (for the security) to extract the token into another variable.

[email protected] ~ % token=$( /usr/bin/osascript -l 'JavaScript' -e "JSON.parse(\`$jsonresponse\`).token" )

[email protected] ~ % echo $token
eyJhbGciOiJIUzI1NiJ9.eyJhdXRoZW50aWNhdGVkLWFwcCI6IkdFTkVSSUMiLCJhdXRoZW50aWNhdGlvbi10eXBlIjoiSlNTIiwiZ3JvdXBzIjpbXSwic3ViamVjdC10eXBlIjoiSlNTX1VTRVJfSUQiLCJ0b2tlbi11dWlkIjoiYWZlNmQyODItOGY5Ni00YTRiLThkNDEtNmI0ZTc2YzliOTZjIiwibGRhcC1zZXJ2ZXItaWQiOi0xLCJzdWIiOiIxMjIiLCJleHAiOjE2MzkwNzI0MDB9.sa7_o2t69kbtEKtWMQqK2zGQSX3NqOsAUKggI1aGulQ

You can now take that token and use it for your purposes!

Now we should really clean up after ourselves. To do that, we need the token and a very specific API request.

/usr/bin/curl -s -k "${jssurl}api/v1/auth/invalidate-token" -H "authorization: Bearer ${token}" -X POST

Now to put all this together into one big script as a proof of concept:

#!/bin/zsh

# POC script for Jamf Pro API

# Variables first

# API user accounts here. One for reading, one for writing back. Security.
# Generate API base64 credentials by using:
# printf "username:password" | iconv -t ISO-8859-1 | base64 -i -
apib64="dXNlcm5hbWU6cGFzc3dvcmQ="

# Current JSS address
jssurl=$( /usr/bin/defaults read /Library/Preferences/com.jamfsoftware.jamf.plist jss_url )

# Use our base64 creds to generate a temporary API access token in JSON form
# Use tr to strip out line feeds or the JXA will not like the input
# Retrieve the read token from the JSON response
jsonresponse=$( /usr/bin/curl -s "${jssurl}api/v1/auth/token" -H "authorization: Basic ${apib64}" -X POST | tr -d "\n" )
token=$( /usr/bin/osascript -l 'JavaScript' -e "JSON.parse(\`$jsonresponse\`).token" )

#
## Do things here
#

# Ok we're done now.
# Invalidate the token
/usr/bin/curl -s -k "${jssurl}api/v1/auth/invalidate-token" -H "authorization: Bearer ${token}" -X POST

# All done
exit 0

The above will take your username password credentials in base64 format, feed them to the Jamf API auth endpoint to get a token then invalidate it when complete. So how do we now use it to do thing? Let’s use an example of retrieving the Jamf ID of the Mac you’re running this on.

The Jamf ID of the Mac lives in the “computers-inventory” endpoint of the new API so we need to make a query there. A quick view of the API docs shows there is exactly ONE option we can use and it returns results for your entire inventory in Jamf. We need to scope that down to only what’s needed.

Thankfully there is a way. The API provides a way of selecting which section of the data is required and a further filter to scope down. I like using the Mac hardware UDID as this is unique. Serial numbers can be ported by Apple from logic board to logic board, but UDID is as far as I’m aware unique. Let’s start by finding that out.

[email protected] ~ % udid=$( /usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformUUID/ { split($0, line, "\""); printf("%s\n", line[4]); }' )

That wasn’t so bad? Now we need to do a search using the “USER_AND_LOCATION” section and the UDID as a filter. Note the weird %3D%22 in the command. The %3D is an equals sign and the %22 is a quote so what we’re actually typing is ==”

That gets you a computer record that looks like this:

[email protected] ~ % computerrecord=$( /usr/bin/curl -s "${jssurl}api/v1/computers-inventory?section=USER_AND_LOCATION&filter=udid%3D%3D%22${udid}%22" -H "authorization: Bearer ${token}" )
[email protected] ~ % echo $computerrecord
{
  "totalCount" : 1,
  "results" : [ {
    "id" : "1",
...
  } ]
}%

With much help from @pico and @tlark on the Mac Admins Slack we were able to generate this code to retrieve the computer id number from the above.

[email protected] ~ % id=$( /usr/bin/osascript -l 'JavaScript' -e "JSON.parse(\`$computerrecord\`).results[0].id" )
[email protected] ~ % echo $id
1

From that, we can now retrieve the Jamf ID of the computer you’re running it all on and from there perform the operations you need and all using built in tools. The entire code looks now like this:

#!/bin/zsh

# POC script for Jamf Pro API

# Variables first

# API user accounts here. One for reading, one for writing back. Security.
# Generate API base64 credentials by using:
# printf "username:password" | iconv -t ISO-8859-1 | base64 -i -
apib64="dXNlcm5hbWU6cGFzc3dvcmQ="

# Current JSS address
jssurl=$( /usr/bin/defaults read /Library/Preferences/com.jamfsoftware.jamf.plist jss_url )

# Hardware UDID of the Mac you're running this on
udid=$( /usr/sbin/ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformUUID/ { split($0, line, "\""); printf("%s\n", line[4]); }' )

# Use our base64 creds to generate a temporary API access token in JSON form
# Use tr to strip out line feeds or the JXA will not like the input
# Retrieve the read token from the JSON response
jsonresponse=$( /usr/bin/curl -s "${jssurl}api/v1/auth/token" -H "authorization: Basic ${apib64}" -X POST | tr -d "\n" )
token=$( /usr/bin/osascript -l 'JavaScript' -e "JSON.parse(\`$jsonresponse\`).token" )

# Use the read token to find the ID number of the current Mac
computerrecord=$( /usr/bin/curl -s "${jssurl}api/v1/computers-inventory?section=USER_AND_LOCATION&filter=udid%3D%3D%22${udid}%22" -H "authorization: Bearer ${token}" )
id=$( /usr/bin/osascript -l 'JavaScript' -e "JSON.parse(\`$computerrecord\`).results[0].id" )

echo "Jamf Computer ID: $id"

# Ok we're done now.
# Invalidate the token
/usr/bin/curl -s -k "${jssurl}api/v1/auth/invalidate-token" -H "authorization: Bearer ${token}" -X POST

# All done
exit 0

And that works! Now we get to the nasty part.

I tried doing this again but using the EXTENSION_ATTRIBUTES section instead. I have a task where I need to know what a particular EA on a Mac is set to. The ID retrieval and all other queries fail hard even though the code is the same, and even the formatting of the JSON output is the same.

The only conclusion that I can draw from this is that certain searches do not generate valid enough JSON output for the JSON.parse to work. I even put the exact output through two different linters to check and though they claimed that the output was ok, the parser still passed on it.

I’m not proud that after nearly 10 hours of hacking at this, I gave up and used three grep commands in a row to get the info I need.

Hopefully this will be enough to get you all going. It’s also worth consulting Rich Trouton’s blog on the API as well, although be aware a few things have changed since it was posted in January 2020.

Now while the new API is advertised as a “modern way for programmatically interacting with Jamf Pro”, and I expect there to be API changes in functionality it’s a little infuriating to be forced into a position where I’m either dealing with some pretty obscure tools in the OS, 3rd party tools like jq to package and maintain or invalid looking outputs. I can only hope this improves somewhat in the future.