apple-icloud-mcp 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -5
- package/THIRD_PARTY_NOTICES.md +6 -80
- package/dist/apple-keys.js +214 -0
- package/dist/bundle.js +123317 -0
- package/dist/calendar/caldav.js +264 -0
- package/dist/calendar/create.js +98 -0
- package/dist/calendar/edit.js +284 -0
- package/dist/calendar/events.js +139 -0
- package/dist/calendar/expand.js +799 -0
- package/dist/calendar/format.js +151 -0
- package/dist/calendar/freetime.js +69 -0
- package/dist/calendar/health.js +46 -0
- package/dist/calendar/ics.js +857 -0
- package/dist/calendar/ids.js +87 -0
- package/dist/calendar/series.js +710 -0
- package/dist/calendar/tools.js +859 -0
- package/dist/calendar/window.js +56 -0
- package/dist/config.js +193 -0
- package/dist/contacts/book.js +324 -0
- package/dist/contacts/edit.js +538 -0
- package/dist/contacts/health.js +33 -0
- package/dist/contacts/model.js +319 -0
- package/dist/contacts/tools.js +716 -0
- package/dist/contacts/vcard.js +359 -0
- package/dist/dav/client.js +396 -0
- package/dist/dav/icloud.js +216 -0
- package/dist/dav/xml.js +524 -0
- package/dist/doctor.js +108 -0
- package/dist/errors.js +162 -0
- package/dist/health.js +88 -0
- package/dist/http.js +426 -0
- package/dist/icloud-auth.js +139 -0
- package/dist/index.js +33 -0
- package/dist/itunes/client.js +233 -0
- package/dist/itunes/health.js +39 -0
- package/dist/itunes/params.js +172 -0
- package/dist/itunes/project.js +198 -0
- package/dist/itunes/throttle.js +27 -0
- package/dist/itunes/tools.js +367 -0
- package/dist/mail/config.js +170 -0
- package/dist/mail/format.js +142 -0
- package/dist/mail/health.js +54 -0
- package/dist/mail/html-text.js +423 -0
- package/dist/mail/imap.js +276 -0
- package/dist/mail/message.js +138 -0
- package/dist/mail/smtp.js +228 -0
- package/dist/mail/tools.js +977 -0
- package/dist/maps/client.js +195 -0
- package/dist/maps/format.js +32 -0
- package/dist/maps/health.js +29 -0
- package/dist/maps/location.js +47 -0
- package/dist/maps/project.js +200 -0
- package/dist/maps/snapshot.js +45 -0
- package/dist/maps/tools.js +834 -0
- package/dist/music/auth-cli.js +319 -0
- package/dist/music/client.js +399 -0
- package/dist/music/common.js +57 -0
- package/dist/music/credentials.js +120 -0
- package/dist/music/health.js +142 -0
- package/dist/music/ids.js +122 -0
- package/dist/music/playlists.js +247 -0
- package/dist/music/project.js +139 -0
- package/dist/music/tools-catalog.js +291 -0
- package/dist/music/tools-extended.js +763 -0
- package/dist/music/tools-library.js +503 -0
- package/dist/music/tools-write.js +473 -0
- package/dist/music/tools.js +19 -0
- package/dist/music/web-token.js +190 -0
- package/dist/music/write-log.js +174 -0
- package/dist/registry.js +42 -0
- package/dist/state.js +57 -0
- package/dist/time.js +241 -0
- package/dist/tools/_confirm.js +169 -0
- package/dist/tools/_shared.js +184 -0
- package/dist/tools/healthcheck.js +157 -0
- package/dist/version.js +4 -0
- package/dist/weather/auth.js +85 -0
- package/dist/weather/client.js +160 -0
- package/dist/weather/format.js +343 -0
- package/dist/weather/health.js +27 -0
- package/dist/weather/schemas.js +100 -0
- package/dist/weather/tools.js +375 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/apple-icloud-mcp/SKILL.md +3 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Unofficial Apple Music, iCloud Calendar/Contacts/Mail, Apple Maps and WeatherKit tools for Claude Code",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.2.0"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"displayName": "Apple iCloud",
|
|
15
15
|
"source": "./",
|
|
16
16
|
"description": "Apple Music playlists and library, iCloud Calendar, Contacts and Mail, Apple Maps, WeatherKit and iTunes search for Claude via MCP — runs anywhere, no Mac required. Unofficial; not affiliated with Apple.",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apple-icloud",
|
|
3
3
|
"displayName": "Apple iCloud",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "Apple Music playlists and library, iCloud Calendar, Contacts and Mail, Apple Maps, WeatherKit and iTunes search for Claude via MCP — runs anywhere, no Mac required. Unofficial; not affiliated with Apple.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Chris Chall"
|
package/README.md
CHANGED
|
@@ -12,8 +12,9 @@ It talks to Apple over the network through Apple's own web APIs and standard pro
|
|
|
12
12
|
IMAP/SMTP), so — unlike Mac-only Apple integrations such as
|
|
13
13
|
[apple-swift-mcp](https://github.com/chrischall/apple-swift-mcp) — it runs anywhere: Linux, Windows, a
|
|
14
14
|
container, or hosted on [mcp-host](#running-on-mcp-host) as a claude.ai connector. The two complement each other:
|
|
15
|
-
`apple-swift-mcp` drives the Mac's own apps (Calendar, Reminders, Contacts, Mail, Messages, Notes, Photos, Maps
|
|
16
|
-
while `apple-icloud-mcp` reaches Apple's web services from anywhere and adds Apple
|
|
15
|
+
`apple-swift-mcp` drives the Mac's own apps (Calendar, Reminders, Contacts, Mail, Messages, Notes, Photos, Maps,
|
|
16
|
+
and Music.app playlists), while `apple-icloud-mcp` reaches Apple's web services from anywhere and adds the Apple
|
|
17
|
+
Music catalog, WeatherKit and iTunes.
|
|
17
18
|
|
|
18
19
|
> [!NOTE]
|
|
19
20
|
> **Unofficial.** This project is not affiliated with, endorsed by or sponsored by Apple Inc. Apple, iCloud,
|
|
@@ -210,6 +211,12 @@ works with **no** Apple Developer account. It is unsupported by Apple and may st
|
|
|
210
211
|
tools that need it say so in their descriptions, and their answers report which backend (`official` or `web`)
|
|
211
212
|
served them.
|
|
212
213
|
|
|
214
|
+
> [!TIP]
|
|
215
|
+
> **On a Mac, you may not need web-player mode at all.** [apple-swift-mcp](https://github.com/chrischall/apple-swift-mcp)
|
|
216
|
+
> makes the same playlist edits — rename, delete, remove, reorder, sort, dedupe, move into folders — in the local
|
|
217
|
+
> Music.app library through Music's own scripting, with no tokens or developer account. It works on songs already in
|
|
218
|
+
> your library, so a catalog song still needs adding first (`apple_music_add_to_library` here).
|
|
219
|
+
|
|
213
220
|
## Running on mcp-host
|
|
214
221
|
|
|
215
222
|
The package ships a [`mint.yaml`](mint.yaml) that mcp-host reads when you register `apple-icloud-mcp`:
|
|
@@ -259,7 +266,7 @@ recorded on disk, so a shared secret does not let one be replayed.
|
|
|
259
266
|
| `apple_music_get_recommendations` | Your personal Apple Music recommendations ("Made for You", "Recently Played" and similar groups), each with its title and the albums, playlists or stations in it (catalog ids). | read | |
|
|
260
267
|
| `apple_music_get_replay` | Apple Music Replay: your top songs, albums and artists for the latest Replay year, or for a given year (with play counts where Apple provides them). | read | |
|
|
261
268
|
| `apple_music_get_ratings` | Whether you have loved or disliked songs, albums, playlists, music videos or stations — catalog or library ids — returning love, dislike or none per id. | read | |
|
|
262
|
-
| `apple_music_create_playlist` | Create a new playlist in your Apple Music library, optionally with tracks (up to 500 catalog or library song ids; added 100 at a time), a description, a folder and public visibility. | additive | |
|
|
269
|
+
| `apple_music_create_playlist` | Create a new playlist in your Apple Music library, optionally with tracks (up to 500 catalog or library song ids; added 100 at a time), a description, a folder and public visibility (not in APPLE_WRITE_MODE=additive). | additive | |
|
|
263
270
|
| `apple_music_add_playlist_tracks` | Append songs (up to 500 catalog or library ids) to the end of one of your library playlists. | additive | |
|
|
264
271
|
| `apple_music_create_folder` | Create a playlist folder in your Apple Music library, at the top level or inside another folder. | additive | |
|
|
265
272
|
| `apple_music_add_to_library` | Add catalog songs, albums, playlists or music videos to your Apple Music library by catalog id (up to 100 per type). | additive | |
|
|
@@ -341,7 +348,7 @@ recorded on disk, so a shared secret does not let one be replayed.
|
|
|
341
348
|
| Value | What is registered |
|
|
342
349
|
|---|---|
|
|
343
350
|
| `none` | Read tools only. |
|
|
344
|
-
| `additive` | Reads, plus writes that only **add** to your own account: create a playlist or folder, append tracks, add to library/favorites, create an event (without attendees, and not in a calendar shared with other people) or a contact. Nothing existing is modified or removed and nothing is sent to anyone. |
|
|
351
|
+
| `additive` | Reads, plus writes that only **add** to your own account: create a (private) playlist or folder, append tracks, add to library/favorites, create an event (without attendees, and not in a calendar shared with other people) or a contact. Nothing existing is modified or removed and nothing is sent to anyone. |
|
|
345
352
|
| `all` (default) | Everything. |
|
|
346
353
|
|
|
347
354
|
Gated tools are not registered at all below their mode, so no prompt or injected instruction can call them.
|
|
@@ -443,12 +450,29 @@ All optional; each service activates when its credentials are present. Values th
|
|
|
443
450
|
Run **`apple_healthcheck`** first. For each service it reports whether credentials are configured (and which
|
|
444
451
|
variables to set if not), whether Apple accepted them just now, the active write mode and the display time zone.
|
|
445
452
|
|
|
453
|
+
The same check runs from a terminal, with no MCP client involved — useful before wiring the server into Claude,
|
|
454
|
+
or to tell a credential problem from a client problem. It reads the shell environment only, not the `env` block
|
|
455
|
+
of `.mcp.json`, Claude Desktop's config or the extension's settings, so export the same values first:
|
|
456
|
+
|
|
457
|
+
```bash
|
|
458
|
+
printf 'App-specific password: '; read -rs ICLOUD_APP_PASSWORD; echo # not echoed, kept out of shell history
|
|
459
|
+
export ICLOUD_APP_PASSWORD ICLOUD_USERNAME=you@icloud.com
|
|
460
|
+
npx -y apple-icloud-mcp doctor # every enabled service
|
|
461
|
+
npx -y apple-icloud-mcp doctor calendar mail # only these
|
|
462
|
+
npx -y apple-icloud-mcp doctor --json # the apple_healthcheck report as JSON
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
It exits 0 when every configured service works (and every service named on the command line is configured,
|
|
466
|
+
enabled and working), 1 otherwise, and 2 for a usage error. It sends each configured service one read-only
|
|
467
|
+
request (the iCloud ones one at a time, so a revoked password is sent once), and prints no secrets. From a
|
|
468
|
+
source checkout, `node dist/index.js doctor` also reads the repo's `.env`.
|
|
469
|
+
|
|
446
470
|
| Symptom | Likely cause |
|
|
447
471
|
|---|---|
|
|
448
472
|
| iCloud "credentials rejected" | The app-specific password was revoked (Apple ID password changed) or the normal password was used. Generate a new app-specific password. |
|
|
449
473
|
| Apple Music 401 | Developer key problem: wrong Team/Key ID, or the key lacks MusicKit. In web mode: the `media-user-token` cookie expired — copy a fresh one. |
|
|
450
474
|
| Apple Music 403 | The Music User Token expired (≈6 months), was minted with a different key, or the account has no Apple Music subscription. Run `music-auth` again (without the developer key: ask its owner for `music-auth --print-developer-token`). |
|
|
451
|
-
| `PLAYLIST_CHANGED` | Apple has not caught up with the
|
|
475
|
+
| `PLAYLIST_CHANGED` | Apple has not caught up with a recent change this server made to the playlist yet (its reads lag writes by seconds), or the playlist was edited elsewhere. Reorders, track removals, duplicate-checked adds and playlist updates refuse to act on such a read. Re-read with `apple_music_get_playlist` and retry. |
|
|
452
476
|
| Times are off by hours | Set `DISPLAY_TZ` to your IANA zone. |
|
|
453
477
|
| Mail times out on a hosted deployment | Check `imap.mail.me.com` / `smtp.mail.me.com` are in the egress allowlist. |
|
|
454
478
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -23,8 +23,8 @@ MPL-2.0 is a file-level license: it covers ical.js's own files and does not exte
|
|
|
23
23
|
| Package | Version | License | Source |
|
|
24
24
|
|---|---|---|---|
|
|
25
25
|
| @chrischall/mcp-utils | 2.7.0 | MIT | https://github.com/chrischall/mcp-utils |
|
|
26
|
-
| @fetchproxy/protocol | 3.2.0 | MIT |
|
|
27
|
-
| @fetchproxy/server | 3.2.0 | MIT |
|
|
26
|
+
| @fetchproxy/protocol | 3.2.0 | MIT | — |
|
|
27
|
+
| @fetchproxy/server | 3.2.0 | MIT | — |
|
|
28
28
|
| @modelcontextprotocol/core | 2.1.0 | MIT | https://github.com/modelcontextprotocol/typescript-sdk |
|
|
29
29
|
| @modelcontextprotocol/server | 2.1.0 | MIT | https://github.com/modelcontextprotocol/typescript-sdk |
|
|
30
30
|
| @pinojs/redact | 0.4.0 | MIT | https://github.com/pinojs/redact |
|
|
@@ -58,7 +58,7 @@ MPL-2.0 is a file-level license: it covers ical.js's own files and does not exte
|
|
|
58
58
|
| split2 | 4.2.0 | ISC | https://github.com/mcollina/split2 |
|
|
59
59
|
| thread-stream | 4.2.0 | MIT | https://github.com/mcollina/thread-stream |
|
|
60
60
|
| timezones-ical-library | 2.3.2 | Apache-2.0 | https://github.com/add2cal/timezones-ical-library |
|
|
61
|
-
| ws | 8.21.3 | MIT |
|
|
61
|
+
| ws | 8.21.3 | MIT | — |
|
|
62
62
|
| zod | 4.6.5 | MIT | https://github.com/colinhacks/zod |
|
|
63
63
|
|
|
64
64
|
## License and notice texts
|
|
@@ -97,64 +97,14 @@ SOFTWARE.
|
|
|
97
97
|
### @fetchproxy/protocol@3.2.0
|
|
98
98
|
|
|
99
99
|
- License: MIT
|
|
100
|
-
- Source: https://github.com/chrischall/fetchproxy
|
|
101
100
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
```text
|
|
105
|
-
MIT License
|
|
106
|
-
|
|
107
|
-
Copyright (c) 2026 Chris Hall
|
|
108
|
-
|
|
109
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
110
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
111
|
-
in the Software without restriction, including without limitation the rights
|
|
112
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
113
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
114
|
-
furnished to do so, subject to the following conditions:
|
|
115
|
-
|
|
116
|
-
The above copyright notice and this permission notice shall be included in all
|
|
117
|
-
copies or substantial portions of the Software.
|
|
118
|
-
|
|
119
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
120
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
121
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
122
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
123
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
124
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
125
|
-
SOFTWARE.
|
|
126
|
-
```
|
|
101
|
+
_An optional dependency that was not installed where this file was generated; see its package on npm._
|
|
127
102
|
|
|
128
103
|
### @fetchproxy/server@3.2.0
|
|
129
104
|
|
|
130
105
|
- License: MIT
|
|
131
|
-
- Source: https://github.com/chrischall/fetchproxy
|
|
132
|
-
|
|
133
|
-
`LICENSE`:
|
|
134
106
|
|
|
135
|
-
|
|
136
|
-
MIT License
|
|
137
|
-
|
|
138
|
-
Copyright (c) 2026 Chris Hall
|
|
139
|
-
|
|
140
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
141
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
142
|
-
in the Software without restriction, including without limitation the rights
|
|
143
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
144
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
145
|
-
furnished to do so, subject to the following conditions:
|
|
146
|
-
|
|
147
|
-
The above copyright notice and this permission notice shall be included in all
|
|
148
|
-
copies or substantial portions of the Software.
|
|
149
|
-
|
|
150
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
151
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
152
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
153
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
154
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
155
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
156
|
-
SOFTWARE.
|
|
157
|
-
```
|
|
107
|
+
_An optional dependency that was not installed where this file was generated; see its package on npm._
|
|
158
108
|
|
|
159
109
|
### @modelcontextprotocol/core@2.1.0
|
|
160
110
|
|
|
@@ -2217,32 +2167,8 @@ SOFTWARE.
|
|
|
2217
2167
|
### ws@8.21.3
|
|
2218
2168
|
|
|
2219
2169
|
- License: MIT
|
|
2220
|
-
- Source: https://github.com/websockets/ws
|
|
2221
|
-
|
|
2222
|
-
`LICENSE`:
|
|
2223
|
-
|
|
2224
|
-
```text
|
|
2225
|
-
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
|
|
2226
|
-
Copyright (c) 2013 Arnout Kazemier and contributors
|
|
2227
|
-
Copyright (c) 2016 Luigi Pinca and contributors
|
|
2228
2170
|
|
|
2229
|
-
|
|
2230
|
-
this software and associated documentation files (the "Software"), to deal in
|
|
2231
|
-
the Software without restriction, including without limitation the rights to
|
|
2232
|
-
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
2233
|
-
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
2234
|
-
subject to the following conditions:
|
|
2235
|
-
|
|
2236
|
-
The above copyright notice and this permission notice shall be included in all
|
|
2237
|
-
copies or substantial portions of the Software.
|
|
2238
|
-
|
|
2239
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
2240
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
2241
|
-
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
2242
|
-
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
2243
|
-
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
2244
|
-
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
2245
|
-
```
|
|
2171
|
+
_An optional dependency that was not installed where this file was generated; see its package on npm._
|
|
2246
2172
|
|
|
2247
2173
|
### zod@4.6.5
|
|
2248
2174
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createPrivateKey } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { expandPath, readEnvVar, signEs256Jwt } from '@chrischall/mcp-utils';
|
|
4
|
+
import { ConfigError, rememberSecret } from './errors.js';
|
|
5
|
+
const PREFIX = {
|
|
6
|
+
music: 'APPLE_MUSIC',
|
|
7
|
+
maps: 'APPLE_MAPS',
|
|
8
|
+
weather: 'APPLE_WEATHERKIT',
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Accept the shapes a .p8 actually arrives in through an env block:
|
|
12
|
+
* - the PEM verbatim (multi-line);
|
|
13
|
+
* - the PEM with literal `\n` escapes (a one-line secret field);
|
|
14
|
+
* - the whole PEM base64-encoded;
|
|
15
|
+
* - just the base64 body between the BEGIN/END lines;
|
|
16
|
+
* - the PEM with its line breaks turned into spaces, or dropped entirely —
|
|
17
|
+
* what a single-line form field does to a pasted multi-line value, and a
|
|
18
|
+
* shape OpenSSL refuses to parse.
|
|
19
|
+
* Every PEM is re-armored canonically (64-column body, LF endings).
|
|
20
|
+
* Returns a normalized PEM, or undefined when the value is none of those.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizePrivateKey(raw) {
|
|
23
|
+
let value = raw.trim().replace(/\\r/g, '').replace(/\\n/g, '\n');
|
|
24
|
+
if (!value.includes('-----BEGIN')) {
|
|
25
|
+
const compact = value.replace(/\s+/g, '');
|
|
26
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(compact))
|
|
27
|
+
return undefined;
|
|
28
|
+
const decoded = Buffer.from(compact, 'base64').toString('utf8');
|
|
29
|
+
if (!decoded.includes('-----BEGIN'))
|
|
30
|
+
return armor('PRIVATE KEY', compact);
|
|
31
|
+
value = decoded;
|
|
32
|
+
}
|
|
33
|
+
const pem = PEM_BLOCK_RE.exec(value);
|
|
34
|
+
// An unrecognisable armor is passed through for `validateEcKey` to reject by name.
|
|
35
|
+
if (!pem)
|
|
36
|
+
return value.trim().replace(/\r\n/g, '\n') + '\n';
|
|
37
|
+
return armor(pem[1], pem[2].replace(/\s+/g, ''));
|
|
38
|
+
}
|
|
39
|
+
/** `-----BEGIN <label>-----<body>-----END <label>-----`, whatever whitespace separates the parts. */
|
|
40
|
+
const PEM_BLOCK_RE = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/;
|
|
41
|
+
function armor(label, body) {
|
|
42
|
+
const lines = body.match(/.{1,64}/g) ?? [];
|
|
43
|
+
return [`-----BEGIN ${label}-----`, ...lines, `-----END ${label}-----`].join('\n') + '\n';
|
|
44
|
+
}
|
|
45
|
+
/** Validate that `pem` is an EC P-256 private key; returns an error string or undefined. */
|
|
46
|
+
export function validateEcKey(pem) {
|
|
47
|
+
try {
|
|
48
|
+
const key = createPrivateKey(pem);
|
|
49
|
+
if (key.asymmetricKeyType !== 'ec')
|
|
50
|
+
return `it is a ${key.asymmetricKeyType ?? 'non-EC'} key, not an EC (ES256) key`;
|
|
51
|
+
const curve = key.asymmetricKeyDetails?.namedCurve;
|
|
52
|
+
// No name means explicit curve parameters OpenSSL could not match to a
|
|
53
|
+
// named curve — so not P-256. Accepting it would mint tokens Apple rejects.
|
|
54
|
+
if (curve !== 'prime256v1')
|
|
55
|
+
return `it uses ${curve ? `curve ${curve}` : 'an unnamed curve'}, not P-256`;
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return 'it could not be parsed as a private key';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function readKeyFile(path, varName, service) {
|
|
63
|
+
try {
|
|
64
|
+
return readFileSync(expandPath(path), 'utf8');
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new ConfigError(service, `Could not read the private key file named by ${varName}.`, [varName], `Check that ${varName} points at a readable .p8 file.`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the developer key for `service`, or throw a `ConfigError` naming
|
|
72
|
+
* exactly which variables are missing.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveDeveloperKey(service, env = process.env) {
|
|
75
|
+
const svcName = service;
|
|
76
|
+
const prefix = PREFIX[service];
|
|
77
|
+
const teamId = readEnvVar('APPLE_TEAM_ID', { env });
|
|
78
|
+
const ownKeyId = readEnvVar(`${prefix}_KEY_ID`, { env });
|
|
79
|
+
const ownKey = readEnvVar(`${prefix}_PRIVATE_KEY`, { env });
|
|
80
|
+
const keyId = ownKeyId ?? readEnvVar('APPLE_KEY_ID', { env });
|
|
81
|
+
let rawKey;
|
|
82
|
+
let keyVar;
|
|
83
|
+
if (ownKey !== undefined) {
|
|
84
|
+
rawKey = ownKey;
|
|
85
|
+
keyVar = `${prefix}_PRIVATE_KEY`;
|
|
86
|
+
}
|
|
87
|
+
else if (readEnvVar('APPLE_PRIVATE_KEY', { env }) !== undefined) {
|
|
88
|
+
rawKey = readEnvVar('APPLE_PRIVATE_KEY', { env });
|
|
89
|
+
keyVar = 'APPLE_PRIVATE_KEY';
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const path = readEnvVar('APPLE_PRIVATE_KEY_PATH', { env });
|
|
93
|
+
keyVar = 'APPLE_PRIVATE_KEY_PATH';
|
|
94
|
+
if (path !== undefined)
|
|
95
|
+
rawKey = readKeyFile(path, keyVar, svcName);
|
|
96
|
+
}
|
|
97
|
+
const missing = [];
|
|
98
|
+
if (!teamId)
|
|
99
|
+
missing.push('APPLE_TEAM_ID');
|
|
100
|
+
if (!keyId)
|
|
101
|
+
missing.push(`APPLE_KEY_ID (or ${prefix}_KEY_ID)`);
|
|
102
|
+
if (rawKey === undefined)
|
|
103
|
+
missing.push(`APPLE_PRIVATE_KEY (or ${prefix}_PRIVATE_KEY / APPLE_PRIVATE_KEY_PATH)`);
|
|
104
|
+
if (missing.length === 0)
|
|
105
|
+
assertPairMatches(service, env);
|
|
106
|
+
if (missing.length > 0) {
|
|
107
|
+
throw new ConfigError(svcName, `${serviceLabel(service)} needs an Apple Developer key, and ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not set.`, missing, `Create a key in the Apple Developer portal (Certificates, Identifiers & Profiles → Keys) with ${capabilityLabel(service)} enabled, then set APPLE_TEAM_ID, APPLE_KEY_ID and APPLE_PRIVATE_KEY (the .p8 contents).`);
|
|
108
|
+
}
|
|
109
|
+
const pem = normalizePrivateKey(rawKey);
|
|
110
|
+
const problem = pem === undefined ? 'it is not a PEM or base64 key' : validateEcKey(pem);
|
|
111
|
+
if (pem === undefined || problem !== undefined) {
|
|
112
|
+
throw new ConfigError(svcName, `The private key in ${keyVar} is unusable: ${problem}.`, [keyVar], `Paste the whole .p8 file (including the BEGIN/END PRIVATE KEY lines) into ${keyVar}; one-line values with \\n escapes and base64 are accepted too.`);
|
|
113
|
+
}
|
|
114
|
+
rememberSecret(rawKey);
|
|
115
|
+
return {
|
|
116
|
+
teamId: teamId,
|
|
117
|
+
keyId: keyId,
|
|
118
|
+
privateKeyPem: pem,
|
|
119
|
+
source: `${ownKeyId ? `${prefix}_KEY_ID` : 'APPLE_KEY_ID'} + ${keyVar}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* A key id names exactly one private key, so the service-specific pair and
|
|
124
|
+
* the shared pair must not be crossed. When BOTH a shared key id and a shared
|
|
125
|
+
* private key are set, the shared id provably belongs to the shared key — so
|
|
126
|
+
* pairing it with `<SVC>_PRIVATE_KEY`, or pairing `<SVC>_KEY_ID` with the
|
|
127
|
+
* shared key, mints tokens Apple rejects with a bare 401 that names neither
|
|
128
|
+
* variable. Refuse that up front, by name. With only half of the shared pair
|
|
129
|
+
* set, crossing is the ordinary single-key setup (e.g. APPLE_MUSIC_KEY_ID +
|
|
130
|
+
* APPLE_PRIVATE_KEY for one Music key) and is allowed.
|
|
131
|
+
*/
|
|
132
|
+
function assertPairMatches(service, env) {
|
|
133
|
+
const prefix = PREFIX[service];
|
|
134
|
+
const ownKeyId = readEnvVar(`${prefix}_KEY_ID`, { env });
|
|
135
|
+
const ownKey = readEnvVar(`${prefix}_PRIVATE_KEY`, { env });
|
|
136
|
+
const sharedKeyId = readEnvVar('APPLE_KEY_ID', { env });
|
|
137
|
+
const sharedKeyVar = readEnvVar('APPLE_PRIVATE_KEY', { env }) !== undefined
|
|
138
|
+
? 'APPLE_PRIVATE_KEY'
|
|
139
|
+
: readEnvVar('APPLE_PRIVATE_KEY_PATH', { env }) !== undefined
|
|
140
|
+
? 'APPLE_PRIVATE_KEY_PATH'
|
|
141
|
+
: undefined;
|
|
142
|
+
if (sharedKeyId === undefined || sharedKeyVar === undefined)
|
|
143
|
+
return;
|
|
144
|
+
if (ownKey !== undefined && ownKeyId === undefined) {
|
|
145
|
+
throw new ConfigError(service, `${prefix}_PRIVATE_KEY is set but ${prefix}_KEY_ID is not, and APPLE_KEY_ID belongs to the shared key in ${sharedKeyVar} — ` +
|
|
146
|
+
'a key id must name the key that signs the token.', [`${prefix}_KEY_ID`], `Set ${prefix}_KEY_ID to the Key ID of the key in ${prefix}_PRIVATE_KEY (Apple Developer portal → Keys), or unset ${prefix}_PRIVATE_KEY to use the shared key.`);
|
|
147
|
+
}
|
|
148
|
+
if (ownKeyId !== undefined && ownKey === undefined && ownKeyId !== sharedKeyId) {
|
|
149
|
+
throw new ConfigError(service, `${prefix}_KEY_ID is set but ${prefix}_PRIVATE_KEY is not, and the shared key in ${sharedKeyVar} belongs to APPLE_KEY_ID — ` +
|
|
150
|
+
'a key id must name the key that signs the token.', [`${prefix}_PRIVATE_KEY`], `Set ${prefix}_PRIVATE_KEY to the .p8 contents of key ${prefix}_KEY_ID, or unset ${prefix}_KEY_ID to use the shared key.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function serviceLabel(service) {
|
|
154
|
+
return service === 'music' ? 'Apple Music' : service === 'maps' ? 'Apple Maps' : 'WeatherKit';
|
|
155
|
+
}
|
|
156
|
+
function capabilityLabel(service) {
|
|
157
|
+
return service === 'music' ? 'Media Services (MusicKit)' : service === 'maps' ? 'MapKit JS' : 'WeatherKit';
|
|
158
|
+
}
|
|
159
|
+
/** Seconds since the epoch. */
|
|
160
|
+
function nowSeconds(now) {
|
|
161
|
+
return Math.floor(now / 1000);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* An Apple Music developer token. Apple allows up to ~6 months; we mint for
|
|
165
|
+
* 12 hours and re-mint, so a revoked key stops working within a day and a
|
|
166
|
+
* leaked token is short-lived.
|
|
167
|
+
*/
|
|
168
|
+
export const MUSIC_TOKEN_TTL_S = 12 * 60 * 60;
|
|
169
|
+
export function mintMusicDeveloperToken(key, now = Date.now()) {
|
|
170
|
+
const iat = nowSeconds(now);
|
|
171
|
+
const exp = iat + MUSIC_TOKEN_TTL_S;
|
|
172
|
+
return { token: signEs256Jwt(key.privateKeyPem, { iss: key.teamId, iat, exp }, { header: { kid: key.keyId } }), expiresAt: exp * 1000 };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The Maps Server API auth token (exchanged at GET /v1/token for a 30-minute
|
|
176
|
+
* access token). `scope: server_api` is what makes it a server token.
|
|
177
|
+
*/
|
|
178
|
+
export const MAPS_AUTH_TOKEN_TTL_S = 60 * 60;
|
|
179
|
+
export function mintMapsAuthToken(key, now = Date.now()) {
|
|
180
|
+
const iat = nowSeconds(now);
|
|
181
|
+
const exp = iat + MAPS_AUTH_TOKEN_TTL_S;
|
|
182
|
+
return {
|
|
183
|
+
token: signEs256Jwt(key.privateKeyPem, { iss: key.teamId, iat, exp, scope: 'server_api' }, { header: { kid: key.keyId } }),
|
|
184
|
+
expiresAt: exp * 1000,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A WeatherKit REST token. The non-standard `id` header (`<team>.<service>`)
|
|
189
|
+
* and the `sub` claim (the Services ID) are both required; a missing `id` is
|
|
190
|
+
* the classic cause of `NOT_ENABLED`.
|
|
191
|
+
*/
|
|
192
|
+
export const WEATHERKIT_TOKEN_TTL_S = 60 * 60;
|
|
193
|
+
export function mintWeatherKitToken(key, serviceId, now = Date.now()) {
|
|
194
|
+
const iat = nowSeconds(now);
|
|
195
|
+
const exp = iat + WEATHERKIT_TOKEN_TTL_S;
|
|
196
|
+
return {
|
|
197
|
+
token: signEs256Jwt(key.privateKeyPem, { iss: key.teamId, iat, exp, sub: serviceId }, { header: { kid: key.keyId, id: `${key.teamId}.${serviceId}` } }),
|
|
198
|
+
expiresAt: exp * 1000,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/** Decode a JWT's payload without verifying it (for expiry and issuer checks on tokens we did not mint). */
|
|
202
|
+
export function decodeJwtPayload(token) {
|
|
203
|
+
const parts = token.split('.');
|
|
204
|
+
if (parts.length !== 3)
|
|
205
|
+
return undefined;
|
|
206
|
+
try {
|
|
207
|
+
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
|
|
208
|
+
const payload = JSON.parse(json);
|
|
209
|
+
return payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : undefined;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
}
|