← 所有文章

通过 Apple 登录会发出四种通知,而不是三种

Apple 那则关于“通过 Apple 登录”服务器到服务器通知的开发者公告,列出了您的接口会收到的三件事:邮件转发偏好的变更、用户在您应用内删除账户,以及 Apple Account 被永久删除。3 而 API 文档定义的是四种彼此独立的事件类型。1

照着公告写出来的实现只会处理三个分支,并且悄无声息地丢掉第四种事件。 公告把 email-enabledemail-disabled 压缩成了关于转发偏好的一条要点。实际上它们是两条独立的通知,携带各自不同的 type 值;如果代码只按 type 分支而没有兜底分支,作者漏掉的那一个就会被直接忽略。

四种事件里还有两种的含义超出了字面,其中一种会改变您应用的认证状态。

摘要

“通过 Apple 登录”会投递四种服务器到服务器通知类型:email-enabledemail-disabledconsent-revokedaccount-deleted1 负载以 JWS 的形式,包裹在一个 JSON 对象的 payload 键下送达,由 Apple 的私钥签名;在据此采取任何动作之前,必须用头部 alg 参数所指定的算法完成验签。1 consent-revoked 会使用户凭据失效,因此它是一个认证事件,而非偏好变更。原生应用在 Apple Account 被删除时收不到任何客户端回调,服务器通知是唯一的信号。1 至于重试与投递语义,Apple 没有任何说明。

四种事件类型

每条通知都在 events 声明中携带一个 type 值。1

type 含义
email-enabled 用户通过“隐藏我的邮件地址”开启了向个人邮箱的邮件转发
email-disabled 用户关闭了邮件转发
consent-revoked 用户撤回了对您应用的授权,其凭据已失效
account-deleted 用户请求永久删除自己的 Apple Account

被公告合并掉的正是这两种邮件事件。它们各自都有独立价值:email-disabled 意味着您发往中继地址的邮件不再能送达用户,email-enabled 意味着又能送达了。把它们当成同一个“偏好已变更”事件来处理,等于逼着自己再回头去查当前状态是什么——而通知本来已经告诉您了。

两种名不副实的事件

consent-revoked 是认证事件。 Apple 的原文描述是:用户“撤回了对您应用使用其 Apple Account 的授权,其凭据随之失效”。1 不是标记为弃用,也不是即将过期。是失效。

如果某个应用把它和邮件事件一同记录下来,只更新一行偏好数据,那么界面上仍会呈现已登录状态,背后却是一套无法再完成认证的凭据。用户会一直看到自己的账户,直到下一次令牌刷新失败,然后看到更糟的画面。正确做法是结束会话并跳转到重新认证,与处理被撤销的 OAuth 授权走同一条代码路径。

account-deleted 可能是您能收到的唯一通知。 Apple 明确说明:当用户永久删除自己的 Apple Account 时,“通过 Apple 登录”会使所有用户令牌失效,并为所有关联应用停用邮件转发;而对于原生应用,系统不会发送客户端回调。1

这句话是“到底值不值得跑一个接口”这个问题最有力的答案。一支只做 iOS、没有服务器到服务器接口的团队,根本没有任何途径得知账户已经不在了。记录仍然留着,中继地址停止工作,而您本应履行的删除义务无从谈起——因为没有任何东西告诉过您有东西需要删。

注册接口

配置在 Certificates, Identifiers & Profiles 中完成:选择 Identifiers,选中您的 App ID,启用“通过 Apple 登录”服务,点击 Configure,填入接口 URL。2

在围绕它做设计之前,几条约束值得先读一遍。2

  • 每个“通过 Apple 登录”应用分组与密钥只能配一个 URL。 不是每个应用一个。
  • 只能在主 App ID 上注册。
  • URL 必须是包含协议、主机和路径的绝对 URIhttps://example.com/path/to/endpoint
  • 接收通知要求 TLS 1.2 或更高版本

Apple 的 API 文档另外补充说,同一个 URL 可以用于多个开发者团队和多个应用。1 与“每个分组一个 URL”的规则合起来读,合理的理解是:可以由单个服务接收全部通知,而各个分组各自注册指向它的地址。Apple 并没有把两者的关系讲清楚,因此把共享接口视作“可行”而非“官方背书”更稳妥,并且在依赖它之前,务必确保处理程序能判断出一条通知属于哪个应用。

TLS 1.2 这条下限连着一个更大的趋势。OS 27 开始对管理流量强制执行更严格的 TLS 要求,同样以 1.2 为下限,另加符合 ATS 的加密套件和证书。今天满足 Apple 这项要求的接口,并不自动符合 ATS,而整体走向是越收越紧,不会放松。

解析负载

投递以 HTTP POST 形式到达,请求体是一个 JSON 对象,签名令牌藏在里面:1

{
    "payload": "<SERVER_TO_SERVER_NOTIFICATION_JWS>"
}

JWS 是被包裹的,不是裸的。 先解析 JSON,取出 payload,然后再验签。如果实现把整个请求体直接丢给 JWS 验证器,第一条通知就会失败,而且报错表现为令牌格式错误,而不是包裹方式弄错了——这会把人引向错误的排查方向。

验签先于解读。负载由 Apple 的私钥以 JSON Web Signature 格式签名,Apple 给出的指示是:检查 JWS,使用头部 alg 参数指定的算法验证签名。1 只有签名通过之后,才去读 events 声明并按 type 分支。

从通用的 JWS 实践里,有两个习惯值得保留:绝不信任任何可能让调用方降级验证的 alg 值;确认令牌的签发方与受众与预期一致,而不是照单全收任何格式正确、由 Apple 签名的令牌。

解码后的结构

验签通过后,一条解码后的 consent-revoked 通知长这样:1

{
    "iss": "https://appleid.apple.com",
    "aud": "com.mytest.app",
    "iat": 1508184845,
    "jti": "abede...67890",
    "events": {
        "type": "consent-revoked",
        "sub": "820417.faa325acbc78e1be1668ba852d492d8a.0219",
        "event_time": 1508184845
    }
}

account-deleted 字段相同,只是 type 不同。邮件类事件会多出两个字段:emailis_private_email

这个结构里有三个细节,如果事先不知道、在线上撞见,会白白耗掉您不少时间。

events 是对象,不是数组。 名字是复数,值却是单个事件。照着名字而不是照着结构写的代码,会去遍历一个字典,然后拿到一堆键名。

is_private_email 是字符串。 Apple 的示例里写的是带引号的 "true",而不是 JSON 的布尔值 true。严格的解码器把它映射成 Bool 会直接失败;宽松的解码器若把任何非空字符串都当作真,则会以错误的理由得到正确的答案,然后在 "false" 上翻车。

sub 是稳定的用户标识符,与登录时拿到的是同一个值,也是您据以定位这条通知所指账户的依据。aud 是您的客户端标识符,正是它让共享接口得以为多个应用分发通知。

关于 Apple 自家示例的一点提醒:两个邮件类负载在 "is_private_email": "true""event_time" 之间漏了一个逗号。把任一段复制进 JSON 解析器,文档都会被判为非法。结构没问题,标点有问题——把它粘进测试夹具的人,会为一个不属于自己的语法错误浪费十分钟。

Apple 的术语也有些飘忽。正文说负载是 JSON Web Signature 格式,包裹层的示例却把值命名为 SERVER_TO_SERVER_NOTIFICATION_JWT1 指的是同一个东西:签名后的 JWT 就是一个负载为 JSON 的 JWS。查阅他们的文档时两个词都会碰到,知道这一点省心。

一个完整的处理程序

正确处理程序的形态,由上述约束自然推导而来。以下用 Python 配合 PyJWT:

import json

import jwt
from jwt import PyJWKClient

# Apple publishes its signing keys as a JWKS. Cache the client;
# it fetches and caches keys rather than hitting Apple per request.
JWKS = PyJWKClient("https://appleid.apple.com/auth/keys")
CLIENT_ID = "com.mytest.app"   # your aud value

def handle_notification(request_body: bytes):
    # 1. The JWS is wrapped in JSON under "payload", not the raw body.
    wrapper = json.loads(request_body)
    token = wrapper["payload"]

    # 2. Resolve the signing key by the token's kid, then verify.
    #    Pin the algorithm. Never read alg from the token to decide.
    signing_key = JWKS.get_signing_key_from_jwt(token)
    claims = jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],
        audience=CLIENT_ID,
        issuer="https://appleid.apple.com",
    )

    # 3. Only now is anything trustworthy.
    event = claims["events"]          # an object, not a list
    apple_user_id = event["sub"]      # stable identifier from sign-in

    match event["type"]:
        case "consent-revoked" | "account-deleted":
            end_all_sessions(apple_user_id)
            mark_account_unlinked(apple_user_id)
        case "email-disabled":
            set_email_forwarding(apple_user_id, enabled=False)
        case "email-enabled":
            set_email_forwarding(apple_user_id, enabled=True)
        case other:
            log_unknown_event(other)   # do not fail silently

其中有几行是承重的。

固定算法。 Apple 当前线上的 JWKS 发布了三把 RSA 密钥,全部是 RS256use: sigkid 各不相同。传入 algorithms=["RS256"] 而不是从令牌里读 alg,堵死了那条经典的降级路径——攻击者送来一个声称 alg: none 的令牌。

kid 取密钥,不要拿第一把。 三把密钥同时在线,这正是密钥轮换从外部看到的样子。抓 keys[0] 的处理程序在 Apple 轮换之前一直好用,轮换之后就会对一部分令牌失败——这种问题调试起来相当折磨人。

校验 audiss 少了这两项,您就会接受任何签名有效的 Apple 令牌,包括为另一个应用签发的那种。

保留兜底分支。 否则第五种事件类型出现时会凭空消失。今天照着 Apple 那份三项公告写出来的实现,丢掉 email-enabledemail-disabled,走的正是这条路。

Apple 没有写进文档的部分

API 文档和账户帮助页面,都没有提到任何投递保证。在两处分别检索重试、重投、确认、状态码、超时和幂等,一无所获。

因此截至本文写作时,以下问题在 Apple 的文档里都没有答案:

  • 投递失败是否会重试,重试几次
  • 若有重试,重试窗口有多长
  • 您的接口应返回什么状态码来表示成功
  • 同一条通知是否可能到达两次

这种缺失会带来设计上的后果——以下是我基于 Apple 所述内容的推断,而非对文档的转述。一个投递语义未经说明的接口,不能被当作权威事件流来对待。防守姿态是:把每条通知当成“有什么东西变了”的提示,与自己的记录做对账,而不是盲目套用事件。让处理程序保持幂等,因为您无法排除重复。不要构建那种“正确性依赖于收全每一条通知”的流程,因为您无法确认自己收全了。

如果您的接口宕机一小时,从文档里您无从判断:是丢失了一小时的账户删除事件,还是它们被排队在了某处。就当丢了来设计。

而且也没有可供测试的官方途径

在两个页面里检索沙箱、模拟、触发,同样一无所获。Apple 没有提供任何按需触发通知的机制。

这就留下一个尴尬的循环。最要紧的两种事件——consent-revokedaccount-deleted——是由用户撤回对您应用的访问权或删除自己的 Apple Account 产生的。要用一条真实的 account-deleted 验证处理程序,就意味着有人得删掉一个 Apple Account。这种测试没人会做第二遍。

可行的替代方案是把问题拆开。照着上面的结构自己构造解码后的负载,用它们对分支逻辑、幂等性和对账逻辑做单元测试。另一边,用一条您真能造出来的真实通知去测传输与签名链路:为一个测试用 Apple Account 撤回授权是可恢复的,删除账户则不可恢复,而撤回授权同样能端到端地跑通包裹解析、kid 查找和签名校验。

无论如何,注册之前请先确认接口可达且能及时响应。注册了却从未验证的接口,正是某支团队几个月后才发现“上线以来的每一条通知都发去了一个证书早已过期的 URL”的原因。

一项已经生效的要求

这件事之所以出现在开发者新闻里:自 2026年1月1日起,位于韩国的开发者在注册新的 Services ID 或更新已有 Services ID 时,若要使用“通过 Apple 登录”将网站与应用关联,必须提供服务器到服务器通知接口。3 Apple 于 2025年10月9日发布了这一公告。

这项要求范围有限,而且已经生效数月,并非什么需要提前准备的事。它的价值在于方向性。Apple 已经开始在至少一个司法辖区把该接口变为强制,其理由具有普遍性:让人们掌控自己分享出去的个人数据,并让账户删除真正传导下去。这套逻辑没有一处是韩国独有的。

既然接口早晚要做,不如赶在监管机构替您定下期限之前把它做出来。

关键要点

给后端工程师: - 处理四种类型,不是三种。email-enabledemail-disabled 是分别到达的。 - 先解析 JSON 请求体、取出 payload,再把任何东西交给 JWS 验证器。 - 用头部 alg 指定的算法验签,之后才读取 events 声明。 - 让处理程序幂等,并与自己的记录对账。Apple 的文档没有给出任何投递保证。

给 iOS 团队: - consent-revoked 会使凭据失效。把它当作终结会话的认证事件,而不是偏好更新。 - Apple Account 被删除时,原生应用收不到客户端回调。没有接口,您永远不会知道这件事发生过。

给还在权衡值不值得做的人: - 对位于韩国的开发者而言,该接口自 2026年1月起已是强制要求,而其背后的理由具有普遍性。

常见问题

一共有几种通知类型?

四种:email-enabledemail-disabledconsent-revokedaccount-deleted1 Apple 的开发者新闻公告只描述了三种,把两个邮件事件合并成了关于转发偏好的一条要点。3

用户的凭据已失效。1 请按处理被撤销的 OAuth 授权那样对待它:结束会话,把用户引导至重新认证,而不是更新一个偏好项然后继续。

如果只发布原生 iOS 应用,还需要接口吗?

Apple 明确说明,Apple Account 被永久删除时,系统不会向原生应用发送客户端回调。1 没有服务器到服务器接口,就没有任何机制会告知您。

接口应该返回什么?宕机了会怎样?

Apple 没有说明状态码预期、重试行为,也没有说明是否会出现重复投递。请按“至少一次”、甚至可能是“至多一次”的投递来设计:让处理程序幂等,与自己的记录对账,不要默认每条通知都已到达。

一个接口能服务多个应用吗?

Apple 的文档表示,同一个 URL 可用于多个开发者团队和应用。1 注册层面是:在主 App ID 上,每个“通过 Apple 登录”应用分组与密钥对应一个 URL。2 共享服务是可行的,前提是您的处理程序能判定每条通知属于哪个应用。

资料来源


  1. Apple, “Processing changes for Sign in with Apple accounts.” 四种事件类型(email-enabledemail-disabledconsent-revokedaccount-deleted)、JWS 负载格式与“使用头部 alg 参数验签”的指示、{"payload": "<JWS>"} 包裹结构、“撤回授权导致凭据失效”的表述、“原生应用在账户删除时收不到客户端回调”的说明、TLS 1.2 服务器要求,以及“同一 URL 可跨多个团队和应用使用”的许可,均出自此处。检索于 2026-08-02。 

  2. Apple, “Enabling server-to-server notifications.” 经由 Certificates, Identifiers & Profiles 的注册路径、“每个应用分组与密钥一个 URL”规则、主 App ID 限制、绝对 URI 要求以及 TLS 1.2 要求,均出自此处。检索于 2026-08-02。 

  3. Apple Developer News, “New requirement for apps using Sign in with Apple for account creation,” 2025年10月9日。2026年1月1日生效的韩国要求,以及“接口会收到什么”的三项摘要,均出自此处。 

相关文章

Fork 炸弹救了我们

LiteLLM 的攻击者只犯了一个实现上的错误。正是这个错误,让 47,000 次安装在 46 分钟内被发现。

1 分钟阅读

仓库不应为自己的信任投票

37天内出现两个Claude Code信任对话框绕过CVE,揭示了加载顺序的失败。一个不变量即可修复:在路径获得信任之前,不解释工作区的任何字节。

1 分钟阅读

我拒绝写的内容

一个博客集群的声音来自于它拒绝发表的内容,而非它发布的内容。类别性、模式性和有趣的拒绝各自塑造着集群的本质。

1 分钟阅读