Auth0 登录态过期问题排查与修复:从 Keychain 到 Refresh Token 的完整链路

最近在项目中遇到一个让人头疼的问题:用户通过 App Store 更新 app 后,打开应用就会被强制跳转到登录页面。这在 production 环境下必现,严重影响用户体验。本文记录了从定位到修复的完整过程,希望能帮到遇到类似问题的同学。

问题现象

用户通过 App Store 更新或 OTA 更新后,打开 app 被强制跳转到登录页面,需要重新登录。该问题在 production 环境下必现。

技术背景

认证架构

项目使用 Auth0 作为认证服务,整体流程如下:

1
2
3
4
5
6
7
登录 → Auth0 返回 credentials(accessToken + refreshToken + idToken)
→ credentialsManager.saveCredentials() 存入 iOS Keychain
→ app 使用 accessToken 调用后端 API

accessToken 过期(默认 24h)→ API 返回 401
→ forceRefreshAccessToken() 用 refreshToken 向 Auth0 换取新 accessToken
→ 重试原请求

相关文件

文件职责
api-auth0-auth-service.tsAuth0 认证服务,登录/登出/token 管理
base.tsAPI 基类,401 拦截与 token 刷新
auth-token-accessor.tstoken 读取/刷新的中间层(去重逻辑)

根因分析

直接原因:Auth0 API 未开启 “Allow Offline Access”

Auth0 Dashboard 中,API 的 “Allow Offline Access” 开关未打开。

虽然 app 代码中登录时请求了 offline_access scope:

1
scope: 'openid profile email offline_access',

但 Auth0 服务端的 API 配置未允许离线访问,因此 Auth0 返回的 credentials 中不包含 refresh token

这是一个很隐蔽的坑——客户端代码看起来一切正确,问题出在服务端配置上。

触发链路

整个问题的触发链路如下:

1
2
3
4
5
6
7
8
9
10
11
1. 用户登录 → Auth0 返回 accessToken(无 refreshToken)→ 存入 Keychain
2. accessToken 24 小时后过期
3. app 调用 API → 后端返回 401
4. base.ts 拦截 401 → 调用 forceRefreshAccessToken()
5. credentialsManager.getCredentials(forceRefresh=true)
6. 抛出 CredentialsManagerError: NO_REFRESH_TOKEN
"The stored credentials instance does not contain a refresh token."
7. forceRefreshAccessToken() 返回 null
8. notifyApiSessionInvalid() → handleUnauthorizedFromApi()
9. clearLocalCredentialsOnly() → 清除 Keychain 中的凭证
10. emit(null) → UI 跳转到登录页

为什么商店更新后必现?

App Store 更新涉及重新安装 app binary。虽然 Keychain 数据会保留(同 bundleId),但更新后首次启动时 accessToken 大概率已过期(用户不一定每天打开 app),从而触发上述链路。

这里有一个 iOS 的知识点值得注意:Keychain 数据不会因为 app 卸载重装而丢失(与 UserDefaults 不同),但 token 的有效期不会因为 Keychain 持久化而延长。

排查过程

第一步:添加 Datadog 日志

在认证链路的所有关键节点添加了 DdLogs 日志,覆盖以下场景:

日志标识级别触发时机
api.401: received unauthorizedwarn收到任何 401 响应
api.401: token refreshed, retryinginforefresh 成功,重试请求
api.401: refresh failed, invalidating sessionerrorrefresh 失败,即将登出
auth.getSession: credential errorwarn无存储凭证(正常未登录状态)
auth.getSession: first attempt failed, retryingerror首次获取凭证失败
auth.forceRefresh: successinfo强制刷新 token 成功
auth.forceRefresh: FAILEDerror强制刷新失败(含 error_code)
auth.handleUnauthorized: clearing credentialserror执行被动登出
auth.getAccessToken: failedwarn获取 accessToken 失败

充分的日志覆盖是排查认证问题的基础,尤其是在 production 环境中无法 attach debugger 的场景。

第二步:本地模拟验证

通过临时修改 base.tsonRequest interceptor,将 Authorization header 替换为无效 token,强制触发 401 链路:

1
config.headers.set('Authorization', `Bearer expired_token_test`);

观察到的关键日志:

1
2
3
[AUTH] forceRefresh: FAILED — will trigger logout
error_code: 'NO_REFRESH_TOKEN'
error_message: 'The stored credentials instance does not contain a refresh token.'

至此确认了 Keychain 中的 credentials 不包含 refresh token。

第三步:开启 Allow Offline Access 后验证

在 Auth0 Dashboard 开启后,重新登录,再次模拟 401:

1
2
[AUTH] forceRefresh: success
[AUTH] api.401: token refreshed, retrying

确认 refresh token 正常工作,问题定位完成。

修复方案

修复 1:Auth0 配置(根本修复)

Auth0 Dashboard → Applications → APIs → game-api → Settings

  • 打开 “Allow Offline Access”
  • 保存

开启后,Auth0 在用户登录时会返回 refresh token,app 的 credentialsManager 会将其存入 Keychain。当 accessToken 过期时,SDK 自动使用 refresh token 换取新 token,用户无感。

修复 2:并发 401 去重(代码修复)

在排查过程中还发现了一个附带问题:多个 API 请求同时收到 401 时,每个请求独立触发 refresh + logout,导致 handleUnauthorized 被调用十余次。

修复思路是在 auth-token-accessor.ts 中做两层去重:

Refresh 去重——多个并发 401 共享同一个 in-flight refresh promise,Auth0 只被调用一次:

1
2
3
4
5
6
7
8
9
10
let inflightRefresh: Promise<string | null> | null = null;

export async function forceRefreshAccessTokenForApi(): Promise<string | null> {
if (!forceRefreshToken) return null;
if (inflightRefresh) return inflightRefresh;
inflightRefresh = forceRefreshToken()
.catch(() => null)
.finally(() => { inflightRefresh = null; });
return inflightRefresh;
}

Session invalid 去重——notifyApiSessionInvalid() 加了一次性标记,直到下次登录成功才重置:

1
2
3
4
5
6
7
let sessionAlreadyInvalidated = false;

export function notifyApiSessionInvalid(): void {
if (sessionAlreadyInvalidated) return;
sessionAlreadyInvalidated = true;
onSessionInvalid?.();
}

登录成功重置标记api-auth0-auth-service.ts):

1
2
3
4
private emit(): void {
if (this.sessionCache) resetSessionInvalidFlag();
// ...
}

这种"共享 in-flight promise"的模式在前端开发中很常见,适用于任何需要对并发重复请求做去重的场景。

Auth0 关键配置检查清单

如果你也在使用 Auth0,建议对照检查以下配置:

配置项位置推荐值说明
Allow Offline AccessAPIs → Settings开启必须开启,否则不返回 refresh token
Access Token LifetimeAPIs → Settings86400 (24h)accessToken 有效期
Refresh Token LifetimeAPIs → Settings按需配置refresh token 有效期
Refresh Token RotationSecurity → Refresh Token按需开启后旧 token 立即失效

总结与后续建议

这次问题的本质是客户端代码与服务端配置不一致——代码请求了 offline_access scope,但服务端 API 未允许。这类配置层面的 bug 往往比代码 bug 更难发现,因为代码层面看起来一切正常。

几点后续建议:

  1. 监控告警:在 Datadog 中设置告警,当 auth.forceRefresh: FAILED 出现时通知,提前发现 refresh token 问题。
  2. Refresh Token Rotation:如果开启了 rotation,注意 Auth0 的 reuse detection 窗口期设置,避免并发请求导致 token 被误判为重放攻击。
  3. 存量用户修复:已安装的用户 Keychain 中的 credentials 仍然没有 refresh token。他们需要等 accessToken 过期后重新登录一次,新的 credentials 才会包含 refresh token。这一点无法通过 OTA 推送修复。