DarkSky to WeatherKit: from API keys to signed JWTs
Tags: weatherkit, programming, csharp, dotnet
Published: 2023-04-20 23:25:02

{% embed https://www.youtube.com/watch?v=oo5fg0EfoCY&t=300
 %}

As of March 31st, the Dark Sky API is no more, replaced by Apple's [WeatherKit](https://developer.apple.com/weatherkit/). I've used the Dark Sky API as it had a generous free-tier, was easy to use and flexible enough to customize. 

Remember a few years ago when customized terminal prompts were all the rage? Pepperidge Farm remembers - especially people like me who probably went a bit overboard. 

![A terminal prompt with too many segments, like weather and stock price](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5jue3d8fp25wbzu4z4hh.png)

Note the weather & stock price. Stock price comes from Yahoo (and follows the same pattern as the weather data), weather data came from Dark Sky. The prompt would read from a cache file. 

```bash
weatherData=$(curl -s "https://api.darksky.net/forecast/${API_KEY}/${LATLON}?exclude=minutely,hourly,daily,alerts,flags")
echo $weatherData > $weatherCacheFile
```

API key was a static string - could be regenerated on-demand. To run on a trusted machine (like my own), it was no sweat and easy to use. Of course I wouldn't want to distribute that key, but it was for my use locally. It was *especially* easy to use in shell scripting. 

## Enter WeatherKit
Apple pushed the Dark Sky cutoff date a few times, of course, I missed it and wondered why I was getting 301s and parse error for weather info. On March 31st, the API was dead and I'd have to migrate over to WeatherKit. 

At first glance I figured it'd be pretty similar - this is weather data, after all - but was surprised to see that Apple's only [accepted authorization scheme](https://developer.apple.com/documentation/weatherkitrestapi/request_authentication_for_weatherkit_rest_api) for the API was a bearer token, an EC-signed, asymmetric JWT! Needless to say I was surprised

![Surprised Pikachu meme](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/flwq3m0mzcnjmnje5ds1.png)

Here's an example:

```json
{
  "kid": "<KEY ID>",
  "id": "<TEAM ID>.<SERVICE ID>",
  "typ": "JWT",
  "alg": "ES256"
},
{
  "iss": "<TEAM ID>",
  "iat": 1682008228,
  "exp": 1682011828,
  "sub": "<SERVICE ID>"
}.<signature>
```

## Get all of your pieces

Sadly, this is not free - unlike the Dark Sky API, this requires an Apple Dev membership. While the WeatherKit API itself has a generous starting tier (500k calls/month) that doesn't have any _additional_ cost, you'll still be out the $99 for the annual membership. Students and non-profits can get it cheaper, so ask around. 

Apple's docs cover most of what you'll need:
- your Team ID
- the `.p8` file that contains your key
- the key ID
- the service ID
- lat/lon coordinates of a location

The Service ID is a string (like `dev.jpda.terminal-weather`) of your choosing. 

## Testing with Postman's excellent JWT signer

To start, Postman has an excellent JWT Bearer auth type that includes an ES256 signer, which makes it much easier to get familiar with the API. I find creating a collection to make this easier (as the authorization settings can be set at the collection level instead of per-request). Here we can configure our token. Choose JWT Bearer as the type, `ES256` as the algorithm, get your private key from the .p8 file from Apple. 

![Postman authorization screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/26a1c21ylrkqxfw66d84.png)

### JWT Payload

![JWT payload screen in postman authz](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9x42fikmcm1qbyzi8nta.png)

The payload is where we'll add our specific claims - there are four we need: `iss`,`iat` (issued at), `exp` (expires on) and `sub`ject (who/what the token is about).

- iss: your team ID
- sub: your service ID

The two timestamps we'll have to sort out. Postman has a `$timestamp` global that gives us a unix epoch time (seconds since 1/1/1970) making `iat` easy. `exp` we'll need calculate off of `iat`, which we can do via Pre-request scripts.

I've left my information in below as an example: 

```json
{
    "iss":"6RT85M24SE",
    "iat": {{$timestamp}},
    "exp": {{exp_timestamp}},
    "sub":"dev.jpda.terminal-weather"
}
```

Pay particular attention to the timestamps - they should *not* be in quotations, as they are longs. This will complain because `exp_timestamp` is not defined, which is OK, we're going to go define it next. 

### Variables & pre-request script

We'll need at least one variable to hold the calculated expiration value. I put my coordinates in variables called `HOME_LAT` and `HOME_LON` since they will be available to the whole collection.

Add a variable called `exp_timestamp` - it doesn't need an initial value.

![Variables screen](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2vpa3umogusl15r9bq9h.png)

Lastly head into your pre-request scripts tab. Here we can calculate the expiration timestamp like so:

```javascript
var now = pm.variables.replaceIn("{{$timestamp}}")*1;
var exp = now + 3600; // adds +1 hour
pm.collectionVariables.set("exp_timestamp", exp);
```

Because of how Postman's variables work, `replaceIn` is required to use the global $timestamp variable.

## JWT Header

![JWT Header screen in postman authz](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5hyvzzf7mikuue4negeh.png)

Back in the Authorization tab, we have one more item to manage - we need to add two fields to the JWT's header - the `kid` (key ID) and `id` - the team ID + service ID of your app. This is under 'advanced configuration.' We can add our claims and postman will handle adding claims like `alg` for the algorithm and `type`.

I've left my values in as an example:

```json
{
    "kid":"PMV7JU5DJJ",
    "id":"6RT85M24SE.dev.jpda.terminal-weather"
}
```

## Ready! Let's get the weather...! Right? 

OK, at long last we are ready to get the temperature! Well, not quite. First we need to see what datasets are available for your location. 

Get a new request (making sure to save it/add it to your collection we just configured for authorization) and drop in this url:

```
https://weatherkit.apple.com/api/v1/availability/{{HOME_LAT}}/{{HOME_LON}}?country=US
```

Make sure your authorization tab is configured for 'inherit from parent' and you have variables set for `{{HOME_LAT}}` and `{{HOME_LON}}` and update your country if necessary. Run this and, if all went well, we should get a response with an array of the available datasets for that location.

```json
[
    "currentWeather",
    "forecastDaily",
    "forecastHourly",
    "forecastNextHour",
    "weatherAlerts"
]
```

If you get an error, take the generated JWT (from the headers tab of the request) and drop it in your favorite JWT decoder (like jwt.io) and check the contents. 

![get authz header](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ot4j3gzof5paq67eytct.png)

Note that these may be out-of-date, to get the specific token used in the failed request, look in the Postman console (View --> Show Console). 

## Ok so *NOW* we can get the weather?

At long last, now that we know what's available, `currentWeather` should give us what we're looking for. Create a new request in Postman in this collection and let's apply what we've determined:

```
https://weatherkit.apple.com/api/v1/weather/en/{{HOME_LAT}}/{{HOME_LON}}?dataSets=currentWeather&country=US
```

Note the `/en/` in the path - use the ISO country code for your region. I am only interested in current weather, so that's the dataset I'll request.

```json
{
    "currentWeather": {
        "name": "CurrentWeather",
        "metadata": {
            "attributionURL": "https://weatherkit.apple.com/legal-attribution.html",
            "expireTime": "2023-04-20T21:20:01Z",
            "latitude": 42.860,
            "longitude": -82.590,
            "readTime": "2023-04-20T21:15:01Z",
            "reportedTime": "2023-04-20T20:02:16Z",
            "units": "m",
            "version": 1
        },
        "asOf": "2023-04-20T21:15:01Z",
        "cloudCover": 0.65,
        "cloudCoverLowAltPct": 0.03,
        "cloudCoverMidAltPct": 0.66,
        "cloudCoverHighAltPct": 0.34,
        "conditionCode": "MostlyCloudy",
        "daylight": true,
        "humidity": 0.55,
        "precipitationIntensity": 0.0,
        "pressure": 1013.66,
        "pressureTrend": "falling",
        "temperature": 18.98,
        "temperatureApparent": 18.33,
        "temperatureDewPoint": 9.65,
        "uvIndex": 2,
        "visibility": 30544.32,
        "windDirection": 147,
        "windGust": 28.80,
        "windSpeed": 16.96
    }
}
```

Success! It's all in metric units, and the docs seem to indicate that's what it is and you'll like it. So a bit of work to convert from Cs to Fs (or Ks if you're adventurous).

## Now what? 

Now that we can at least _get_ the data again, how are we going to leverage it? Remember I was using this in my terminal - using `curl` to go fetch the data with an environment variable with the API key. What's best here?

Whatever we use is going to need to access the private key - meaning whatever is generating those JWTs will need to be trusted - local, remote, etc. 

### Local executable

This is pretty straightforward, could easily balance JWT lifetime with overhead. Perhaps once-per-day with a 24-hour JWT? I wouldn't normally advocate for long-lived tokens but as it's locally generated, and it's relatively low-risk data, it seems like a decent tradeoff. 

### Remote

Something remote sounds neat too - like a Lambda or Azure Function - but once we leave our trusted space, we'll have to figure out how only the correct clients can get tokens to call the weather service. Something like AAD, Okta, Auth0, etc to secure the API to get JWTs. Which, at that point, maybe is easier to just let the function/etc get the weather data itself _and cache it_, further reducing the number of paid API calls to WeatherKit. Something like:

Client --> (secured via cloud auth service) http proxy API (keeps Apple private key + generates JWTs) --> WeatherKit

This is, in fact, a lot like the BFF pattern (backends for frontends) used for things like storing secrets securely, caching API responses, etc. 

Remote has tradeoffs, of course, like hosting, downtime, etc. And it's one more thing to break :D

## Next, we'll pick!

[Join me on Twitch](https://aka.wtf/twitch) and we'll decide what's next!