效率工具

给 Apple Music API 签发开发者令牌(developer token)

给 Apple Music API 签发开发者令牌(developer token):用从开发者后台下载的 .p8 私钥按 ES256 签名,检查 Key ID 和 Team ID 的格式,有效期不超过官方规定的上限(15777000 秒),网页使用时加 origin 限制。后面两篇都要用到这个令牌。

BINBHO · 图文教程

给 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 所有接口的前提。把签发写成一个带校验的小函数,后面的搜索服务和网页播放器都调用它,私钥始终只放在服务器上。