Skip to content

Commit 081d4ca

Browse files
committed
docs(auth): stop claiming the Apple token lasts ten minutes
It lasts about a day. Reading `exp` off a real token on 2026-07-30 gave ~23.4 hours; the app's own "会话 剩余 N 分" line, which parses that claim, showed 1400+ minutes. The wrong number has a history worth keeping. This repo first said "about 24 hours", someone then "corrected" it to "about 10 minutes" and annotated the 24 hours as an observation error — and the correction was the error. It spread into four files and, worse, into a label the user reads on screen: "Apple 的登录凭据 只有约十分钟有效期". So the fix is not a better number. Nothing states a lifetime any more: - the on-screen line already shows real time remaining, parsed from `exp` - the warning text below it no longer names a duration - comments point at `exp` instead of asserting a value The 10-minute fallback in GatewayCredential.init stays, now labelled for what it is: a deliberately pessimistic floor for a token whose `exp` cannot be parsed, not an estimate of the real lifetime. This also right-sizes a product decision I had been describing wrongly. Dropping amdl-portal costs a sign-in roughly once a day, not one every ten minutes — still a regression against the portal's 60-day refresh, but nowhere near the one the old wording implied. No behaviour change: comments and one user-facing string. Signed-off-by: LYJW131 <lyjw2007@gmail.com>
1 parent 0a4af11 commit 081d4ca

5 files changed

Lines changed: 46 additions & 30 deletions

File tree

AGENTS.md

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Concrete invariants worth knowing before you touch them:
3838

3939
Prefer making a caller `async` over spawning an unstructured `Task`.
4040

41-
## Auth: the credential is Apple's own token, and it expires in ten minutes
41+
## Auth: the credential is Apple's own token, and it cannot be renewed
4242

4343
`GatewayAuth.swift` is the whole of it. The app signs in natively, keeps the
4444
Apple identity token, and sends **that token itself** as
@@ -47,10 +47,17 @@ against Apple's JWKS and checks an email allow-list. It issues nothing of its
4747
own and does not report who the caller is, because there is one user and
4848
nothing downstream has anywhere to put a name.
4949

50-
**An Apple identity token lives about ten minutes and there is no silent way to
51-
mint another**`getCredentialState` reports that the authorization still
52-
stands, it does not issue a token. So the app re-prompts. That is a known,
53-
accepted cost, not a bug to fix here:
50+
**There is no silent way to mint another Apple identity token**
51+
`getCredentialState` reports that the authorization still stands, it does not
52+
issue a token. So the app re-prompts when the current one expires. That is a
53+
known, accepted cost, not a bug to fix here:
54+
55+
**Do not write a lifetime into any comment or any string.** The repo said "about
56+
24 hours", someone "corrected" it to "about 10 minutes" as an observation error,
57+
and the 10 minutes then propagated into four documents and one user-facing
58+
label. Reading `exp` off the real token on 2026-07-30 gave **~23.4 hours** — the
59+
"correction" was the error. The code parses `exp` precisely so nobody has to
60+
believe a number in a comment; leave it that way.
5461

5562
- `amdl-portal` existed to remove it (identity token → its own access/refresh
5663
pair, 1 hour / 60 days). The portal was deleted when the system went back to
@@ -64,8 +71,8 @@ accepted cost, not a bug to fix here:
6471
*usable*, not just present — otherwise the UI would claim you are signed in
6572
while every request 401s.
6673

67-
Expiry is read from the token's own `exp` claim, not "received + 10 min": ten
68-
minutes is a measured value, not a contract.
74+
The 10-minute fallback in `GatewayCredential.init` is a deliberately pessimistic
75+
floor for an unparsable token, **not** an estimate of the real lifetime.
6976

7077
The credential lives in the **Keychain** (`GatewayCredentialStore`), access
7178
group = the App Group id, `AfterFirstUnlock` so the notification extension can

amdl-ios/AppleAuth.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import Foundation
88
/// `--skip-jwt-bearer-tokens`,会用 Apple 公钥自己验签)。
99
///
1010
/// 中间有一版不是这样:`amdl-portal` 用 identity token 换一对自己的
11-
/// access/refresh,为的是绕开"identity token 只活约 10 分钟且无法静默续期"。
11+
/// access/refresh,为的是绕开"identity token 无法静默续期"(当时以为它只活十分钟,
12+
/// 实际约一天,见 `GatewayCredential`)。
1213
/// 整套系统改回单用户设计时门户被删了,这条路也就跟着回到了直发 —— 连带那个
1314
/// 每隔十几分钟弹一次面板的代价。取舍的完整说明在 `GatewayCredential` 的注释里。
1415
///
@@ -72,7 +73,7 @@ enum AppleAuthCredentialStore {
7273

7374
/// 清掉旧版本留在明文 plist 里的 Apple identity token。
7475
///
75-
/// 每次启动都跑一次,代价是两次 `removeObject`。它早就失效了(10 分钟寿命)
76+
/// 每次启动都跑一次,代价是两次 `removeObject`。它早就失效了,
7677
/// 所以这不是功能问题;但一份用户凭据留在会进备份的明文文件里,删掉才对。
7778
static func purgeLegacyIdentityToken() {
7879
defaults?.removeObject(forKey: legacyTokenKey)
@@ -146,7 +147,7 @@ enum AppleAuthError: LocalizedError {
146147
/// 那个 token 本身就是发给网关的凭据。
147148
///
148149
/// 中间有一版是两步 —— 第二步拿 identity token 去 `POST /api/gw/auth/apple/native`
149-
/// 换门户的 access/refresh。那一步是为了绕开 identity token 只活十分钟这件事
150+
/// 换门户的 access/refresh。那一步是为了绕开 identity token 无法续期这件事
150151
/// 门户删掉之后它没有了,代价见 `GatewayCredential`。
151152
@MainActor
152153
@Observable

amdl-ios/DebugView.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,16 @@ struct DebugView: View {
7979
if appleAuth.isSignedIn {
8080
LabeledContent("账号", value: appleAuth.email ?? "已登录")
8181
LabeledContent("会话", value: appleTokenStatusText)
82-
// 凭据是 Apple 的 identity token 本身,只活约十分钟,而且没有
83-
// 静默续期的办法。所以过期是**常态**而不是异常,界面必须直说
84-
// 一句,否则用户看到的只是"每隔一会儿就要重新登录一次",像是
85-
// 坏了。取舍的来龙去脉见 GatewayCredential 的注释。
82+
// 凭据是 Apple 的 identity token 本身,没有静默续期的办法,所以
83+
// 过期是**常态**而不是异常,界面要直说一句,否则用户看到的只是
84+
// "隔一阵就要重新登录",像是坏了。
85+
//
86+
// 不要在这句话里写死时长。上面「会话」那行显示的是从 token 的
87+
// `exp` 解出来的真实剩余时间;写死数字正是之前出过的错——文案说
88+
// 十分钟,实际约一天。取舍见 GatewayCredential 的注释。
8689
if !appleAuth.hasValidToken {
8790
Label {
88-
Text("登录已过期,重新登录一次即可。Apple 的登录凭据只有约十分钟有效期,而且无法自动续期")
91+
Text("登录已过期,重新登录一次即可。Apple 的登录凭据不能自动续期,到期后需要手动登录")
8992
} icon: {
9093
Image(systemName: "clock.badge.exclamationmark")
9194
}

amdl-ios/GatewayAuth.swift

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,37 @@ import Security
77
/// 直接验这个 token 的签名,再比一遍邮箱白名单。它不发自己的令牌、不存会话、
88
/// 也不关心调用者是谁 —— 只回答"过,还是不过"。
99
///
10-
/// ## 这里有一个已知的体验代价,不是 bug
10+
/// ## 有效期:读 token 自己说的,不要猜
1111
///
12-
/// **Apple 的 identity token 实测只活约 10 分钟**,而且没有任何静默续期手段:
13-
/// `getCredentialState` 只告诉你授权还在,不会签发新 token。所以 token 一过期,
14-
/// 下一个请求就是 401,用户得重新弹一次系统登录面板。
12+
/// 到期时刻是从这个 JWT 的 `exp` claim 解出来的,不是按经验值估的。**这一点是有
13+
/// 来历的**:仓库里先写着「约 24 小时」,后来被"修正"成「约 10 分钟」并注明前者是
14+
/// 观察错误——而 2026-07-30 在真机上读出来的 `exp` 是 **约 23.4 小时**,也就是说
15+
/// 被改掉的那个才是对的,"修正"是错的,并且这个错在文档和界面文案里传播了一圈。
1516
///
16-
/// 这正是 `amdl-portal` 当初存在的理由 —— 它用 identity token 换一对
17-
/// access/refresh(1 小时 / 60 天),App 因此能连着用两个月不弹面板。整套系统
18-
/// 改回单用户设计时门户被删掉了,这个代价就跟着回来了。
17+
/// 所以这里不写死任何数字。Apple 想改随时可以改,而 `exp` 是这个 token 自己说的话。
18+
///
19+
/// 实际代价:**大约一天重新登录一次**,因为没有静默续期手段
20+
/// (`getCredentialState` 只告诉你授权还在,不会签发新 token)。
21+
///
22+
/// 这比 `amdl-portal` 那一版(access 1 小时 / refresh 60 天、可连用两个月)仍然是
23+
/// 退步,但退得远没有"每十几分钟弹一次面板"那么严重——那个说法是基于上面那个错误
24+
/// 数字得出的。要不要为此再造一层服务端会话,是产品判断,请按一天一次来权衡。
1925
///
20-
/// 要消掉它,只有让**服务端**签发长效令牌,而那无论写得多薄都是一层服务端会话。
21-
/// 那不是 App 侧能修的东西,也别在这里想办法绕 —— 唯一"能绕"的做法是把 token 存得
22-
/// 更久一点,而那只会让请求带着一个必定被拒的凭据出门。
2326
nonisolated struct GatewayCredential: Codable, Sendable {
2427
let identityToken: String
2528
/// 从 token 自己的 `exp` claim 解出来的到期时刻。
2629
///
27-
/// 解 JWT 而不是"收到时间 + 10 分钟":10 分钟是实测值不是契约,Apple 想改随时
28-
/// 可以改,而 `exp` 是这个 token 自己说的话。解不出来时按 10 分钟兜底。
30+
/// 解 JWT 而不是按经验值加一个偏移——见上面为什么。解不出来时按 10 分钟兜底,
31+
/// 那**不是**对真实寿命的估计,而是刻意悲观:宁可早问一次,也不要带着一个已经
32+
/// 失效的凭据出门。
2933
let expiresAt: Date
3034

3135
/// 留 30 秒余量:请求在路上过期就是白跑一趟 401。
3236
var isUsable: Bool { expiresAt.timeIntervalSinceNow > 30 }
3337

3438
init(identityToken: String, receivedAt: Date = Date()) {
3539
self.identityToken = identityToken
40+
// 兜底 10 分钟是刻意保守的下限,不是观测值;见 expiresAt 的注释。
3641
self.expiresAt = Self.expiry(ofJWT: identityToken) ?? receivedAt.addingTimeInterval(600)
3742
}
3843

@@ -58,8 +63,8 @@ nonisolated struct GatewayCredential: Codable, Sendable {
5863
/// 凭据的持久化。
5964
///
6065
/// **放在 Keychain 而不是 App Group 的 UserDefaults**:UserDefaults 的 plist 是明文、
61-
/// 会进 iTunes/iCloud 备份、也没有"设备解锁后才可读"这种保护。这个 token 只活十分钟
62-
/// 危害确实比一份 60 天的 refresh token 小,但十分钟里它就是这套部署的通行证 ——
66+
/// 会进 iTunes/iCloud 备份、也没有"设备解锁后才可读"这种保护。这个 token 大约活一天
67+
/// 危害比一份 60 天的 refresh token 小,但这一天里它就是这套部署的通行证 ——
6368
/// 而且更实际的理由是:早先版本正是把它明文存在 UserDefaults 里,那是个被专门修掉的
6469
/// 问题,不该因为门户没了就退回去。`AppleAuthCredentialStore.purgeLegacyIdentityToken()`
6570
/// 每次启动还在清那份旧的。

amdl-ios/amdl_iosApp.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ struct amdl_iosApp: App {
3333
//
3434
// 这里以前问的是门户"我这个账号被批准了没有"。没有账号可问了,
3535
// 但**有一件事必须在用户动手之前知道**:手上这份 token 还能不能
36-
// 用。它只活约十分钟,所以"上次用还好好的"完全不说明问题,而
36+
// 用。它不能续期、大约一天就到期,所以"上次用还好好的"不说明问题,而
3737
// 没有这一下,用户会在第一次提交下载失败时才发现要重新登录。
3838
AppleAuthStore.shared.refreshFromStore()
3939
}

0 commit comments

Comments
 (0)