给 Apple Music API 签发开发者令牌(developer token):用从开发者后台下载的
.p8私钥按 ES256 签名,检查 Key ID 和 Team ID 的格式,有效期不超过官方规定的上限(15777000 秒),网页使用时加origin限制。后面两篇都要用到这个令牌。
先看清成品
pytest 跑了 3 个测试,全部通过:用本地生成的 P-256 密钥签名,再用公钥验签,header 和 claims 与官方示例的格式一致;有效期正好等于 15777000 秒时允许,183 天时拒绝;Key ID 格式错误时报错。第一版测试被 PyJWT 拒绝(见调试示例)。用本地密钥签出的令牌请求官方测试接口
https://api.music.apple.com/v1/test,返回401,因为 Apple 不认识这把密钥,属于预期结果。没有用真实的开发者账号和密钥测试。
注意:Apple Music API 和 MusicKit 要求你是 Apple Developer Program 会员(会员费以官方页面为准,本文不列数字);完整播放还需要听众自己有 Apple Music 订阅。只使用你所在地区的 storefront,不要用它绕过地区限制;不要下载、录制或转存音频;不要共享 Apple ID 或 .p8 私钥。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
make_token.py |
命令行和函数两种用法 |
| 校验 | ID 格式、最长有效期 |
origin 限制 |
网页令牌只能在你的域名下使用 |
| 测试 | 本地生成密钥并验签,不需要 Apple 账号 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Apple Developer Program | 会员 | 由账号持有人(Account Holder)或管理员(Admin)操作 |
| Media ID | Certificates, Identifiers & Profiles → Identifiers → + → Media IDs,勾选 MusicKit | 描述会作为应用名显示给用户 |
| 私钥 | Keys → + → 勾选 Media Services,关联 Media ID,下载 .p8 |
只能下载一次,请妥善保存 |
| Python | 3.9 以上(本机 3.13) | pip install -r requirements.txt |
注意:如果给 App ID 勾选 MusicKit,那是给原生 App 用的。服务端调用 API 要用 Media ID。如果 Keys 页面里 Media Services 是灰色的,说明还没有注册 Media ID。
文件结构
32-apple-music-A-src/
├── make_token.py
├── test_token.py
└── requirements.txt
第 1 步:官方规则
| 位置 | 字段 | 要求 |
|---|---|---|
| header | alg |
只能是 ES256,否则返回 401 |
| header | kid |
10 位 Key ID |
| payload | iss |
10 位 Team ID(后台 Membership 页面) |
| payload | iat |
签发时间,Unix 秒 |
| payload | exp |
距现在不超过 15777000 秒(约 6 个月) |
| payload | origin |
可选;官方建议网页客户端使用 |
第 2 步:脚本
make_token.py:
"""生成 Apple Music API 的开发者令牌(developer token)。
规则来自官方 “Generating Developer Tokens”:ES256 签名;header 带 10 位 kid;
payload 的 iss 是 10 位 Team ID,iat 是签发时间,exp 距现在不超过 15777000 秒(6 个月);
网页客户端建议加 origin 限制。
用法:python make_token.py AuthKey_XXXXXXXXXX.p8 KEYID TEAMID [天数] [--origin https://example.com ...]"""
import re, sys, time
import jwt
MAX_AGE = 15777000
def make_token(p8_pem, key_id, team_id, days=30, origins=None, now=None):
if not re.fullmatch(r"[A-Z0-9]{10}", key_id): raise ValueError("Key ID 应为 10 位大写字母或数字")
if not re.fullmatch(r"[A-Z0-9]{10}", team_id): raise ValueError("Team ID 应为 10 位大写字母或数字")
iat = int(now if now is not None else time.time())
exp = iat + int(days * 86400)
if exp - iat > MAX_AGE: raise ValueError(f"有效期不能超过 {MAX_AGE} 秒(约 182 天)")
payload = {"iss": team_id, "iat": iat, "exp": exp}
if origins: payload["origin"] = list(origins)
return jwt.encode(payload, p8_pem, algorithm="ES256", headers={"kid": key_id})
if __name__ == "__main__":
args = sys.argv[1:]
origins = args[args.index("--origin") + 1:] if "--origin" in args else None
if origins: args = args[:args.index("--origin")]
p8, kid, team = args[:3]
days = float(args[3]) if len(args) > 3 else 30
print(make_token(open(p8).read(), kid, team, days, origins))
用法:
pip install -r requirements.txt
python make_token.py AuthKey_ABC123DEFG.p8 ABC123DEFG DEF123GHIJ 30 --origin https://example.com
第 3 步:测试
test_token.py:
import jwt, pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from make_token import make_token, MAX_AGE
@pytest.fixture(scope="module")
def keypair(): # 本地生成一对 P-256 密钥,模拟从开发者后台下载的 .p8
k = ec.generate_private_key(ec.SECP256R1())
pem = k.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()).decode()
return pem, k.public_key()
def test_header_and_claims(keypair):
pem, pub = keypair
t = make_token(pem, "ABC123DEFG", "DEF123GHIJ", days=30, origins=["https://example.com"], now=1_800_000_000)
assert jwt.get_unverified_header(t) == {"alg": "ES256", "kid": "ABC123DEFG", "typ": "JWT"}
c = jwt.decode(t, pub, algorithms=["ES256"], options={"verify_exp": False, "verify_iat": False})
assert c == {"iss": "DEF123GHIJ", "iat": 1_800_000_000, "exp": 1_800_000_000 + 30 * 86400, "origin": ["https://example.com"]}
def test_max_age(keypair):
pem, _ = keypair
make_token(pem, "ABC123DEFG", "DEF123GHIJ", days=MAX_AGE / 86400) # 正好 15777000 秒:允许
with pytest.raises(ValueError, match="有效期"):
make_token(pem, "ABC123DEFG", "DEF123GHIJ", days=183)
def test_ids(keypair):
with pytest.raises(ValueError, match="Key ID"):
make_token(keypair[0], "abc", "DEF123GHIJ")
python -m pytest -q # 3 passed
第 4 步:验证令牌(本机只用假密钥测过)
TOKEN=$(python make_token.py AuthKey_XXXX.p8 KEYID TEAMID 1)
curl -i -H "Authorization: Bearer $TOKEN" https://api.music.apple.com/v1/test
用真实密钥签发的令牌应该返回 200。本机用自己生成的密钥得到的是 401,而且响应体为空,不带 Authorization 头时也一样。所以 401 只说明“令牌不被接受”,具体原因要自己排查:密钥、kid、iss、是否过期。
调试示例
测试报 ImmatureSignatureError
第一版测试为了让结果可复现,固定了 now=1_800_000_000(这个时间在未来)。验签时 PyJWT 报错:
ImmatureSignatureError: The token is not yet valid (iat)
PyJWT 会检查 iat 是否晚于当前时间。测试里加上 verify_iat: False 之后就通过了。这个报错也提醒了一点:服务器时钟不准时,签出来的令牌可能被判定为“还没生效”,所以签发令牌的机器要开启时间同步。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 401 | 用了别的算法、kid 或 iss 不对、令牌过期,或者密钥被吊销了 | 逐项核对;只能用 ES256 |
| Keys 页面里 Media Services 是灰色的 | 还没有注册 Media ID | 先去 Identifiers 里创建 Media ID |
找不到 .p8 文件 |
私钥只能下载一次 | 新建一把密钥,旧的吊销掉 |
| 429 | 令牌在短时间内请求太多 | 降低请求频率,官方说明过一会儿就会恢复 |
模板规则
.p8私钥只放在服务器上,绝不放进前端或代码库。- 网页用的令牌有效期设短一些,并加上
origin。 - 怀疑密钥泄露时,先新建一把密钥并切换过去,再吊销旧的(官方建议)。
项目资源
| 用途 | 地址 |
|---|---|
| Generating Developer Tokens | https://developer.apple.com/documentation/applemusicapi/generating-developer-tokens |
| 创建 Media ID 和私钥 | https://developer.apple.com/help/account/capabilities/create-a-media-identifier-and-private-key/ |
| 创建私钥(Keys) | https://developer.apple.com/help/account/keys/create-a-private-key |
| Apple Developer Program | https://developer.apple.com/programs/ |
| Apple Music API 文档 | https://developer.apple.com/documentation/applemusicapi |
| PyJWT 文档 | https://pyjwt.readthedocs.io/en/stable/ |
开发者令牌是 Apple Music 所有接口的前提。把签发写成一个带校验的小函数,后面的搜索服务和网页播放器都调用它,私钥始终只放在服务器上。