Skip to content

feat(auth): add the frontend auth session layer - #163

Merged
nighca merged 7 commits into
1024XEngineer:mainfrom
huyanxius:feat/158-auth-session
Aug 7, 2026
Merged

feat(auth): add the frontend auth session layer#163
nighca merged 7 commits into
1024XEngineer:mainfrom
huyanxius:feat/158-auth-session

Conversation

@huyanxius

@huyanxius huyanxius commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

补齐前端认证 API 与全局会话状态,让业务请求在 access token 失效后能自行续期重放,为后续的账号界面与路由守卫提供统一登录态。

Closes #158

Why

后端鉴权中间件的白名单只放行 /auth/* 与文档、健康检查,其余接口一律要求有效的 access token。前端目前没有任何模块持有会话,shared/api 预留的 registerApiAccessTokenProvider 至今无人注册,所有需要鉴权的请求都会被拒。

会话层不能只有一条定时续期路径。标签页被挂起时定时器不会准时触发,醒来后 access token 已过期,应用却仍停留在已登录状态,用户看到的是一连串失败的请求。/auth/refresh 又是轮换式的,两个标签页各自续期会互相使对方的 refresh token 失效。这两种情况在日常使用中都很常见。

Change Description

  • entities/user:八个认证端点的接口契约与 HTTP 适配器,以及前端领域层的稳定用户模型。
  • features/auth-session:booting、guest、authenticated 三态会话 Provider,覆盖启动恢复、登录、注册、登出、改密后失效与提前续期。
  • access token 只驻留内存,refresh token 持久化到 localStorage;存储不可用时降级为仅内存会话。
  • 跨标签页同步登录、登出与 token 轮换。
  • 业务响应 code=401 时尝试续期并最多重放一次;认证接口自身关闭该机制,避免 refresh 的 401 递归。
  • 在应用入口装配 AuthSessionProvider。本条不含任何界面与路由守卫。

Implementation Approach

实现参考已关闭的 PR #147,会话分层与存储思路来自该实现,原作者 xyh202131。本分支没有 cherry-pick 其提交,而是按当前后端契约重写:实体契约与端点调用形状大量沿用,会话核心与存储层重新实现,并补齐响应式续期、跨标签页同步与并发保护。

唯一需要单独说明的判断依据:登录失效看的是响应封套里的 code === 401,不是 HTTP 状态码——后端的 401 与 429 都以 HTTP 200 返回。请求层只在 HTTP 200 且业务码为 401 时调用恢复器,重放至多一次;恢复失败、请求体不可重放、或第二次仍是 401,都保留原始错误。其余的并发去重、generation 防陈旧与跨标签页策略见代码内注释。

Screenshots

本条不含可见 UI,合入后界面没有变化。可见部分从 #159 开始。

Testing

Node.js v24.18.0,在 frontend/ 下依次执行:

命令 结果
npm run format:check 通过
npm run lint 通过
npm run typecheck 通过
npm run test 通过,20 个测试文件 / 103 项测试
npm run build 通过,105 个模块

跨标签页路径由 jsdom 的 StorageEvent 覆盖,没有做双标签页的真实浏览器验证;后端接口以本地 fixture 模拟,没有对接真实服务。

Follow-ups

Related

#157(产品口径)、PR #75(后端)、PR #147(起点)、#153#156(后端待修)。后续 #159#160#161 依赖本条。

Business authentication failures arrive in successful HTTP envelopes.

Add a scoped recovery provider and replay eligible requests once with the latest token.

Authentication clients can opt out to prevent recursive refresh attempts.
Authentication endpoints expose backend DTOs that do not match frontend naming.

Map all eight operations and validate successful user and token payloads explicitly.

Callers receive a stable user contract while authentication requests avoid recursive recovery.
Feature modules consume entities through the repository public barrel.

Expose user models, API types, the factory, and the lazy default instance.

Authentication features can depend on the user entity without bypassing layer boundaries.
Refresh tokens need durable storage without making browser storage a login prerequisite.

Add the contracted key with an in-memory fallback and failure tombstones.

Storage errors now preserve the current tab session and cannot resurrect stale credentials.
Authentication state must survive refreshes while access tokens remain memory-only.

Add session transitions, scheduled and reactive renewal, and cross-tab synchronization.

Generation gates and per-token deduplication prevent stale refreshes from corrupting newer sessions.
The application root needs the real backend authentication session.

Wrap App with AuthSessionProvider and inject the lazy user API instance.

Runtime requests now share the mounted access-token and recovery lifecycle.
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
windup Ready Ready Preview Aug 7, 2026 2:57am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found in this review.

Verification note: I could not run the frontend Vitest/typecheck scripts because the local vitest and tsc executables returned Permission denied.

@huyanxius

Copy link
Copy Markdown
Collaborator Author

@fennoai PR #163 / features/auth-session/index.tsx 的 rotateLatestTokens(122–152 行):

它在成功和失败两条路径上都做了同一件事——如果发现别的标签页写入了更新的 refresh token,就改用那个重新续期。成功路径里这次重新续期写在同一个 try 中,失败了会掉进下面的 catch;而 catch 判断「有没有没试过的 token」时,比较的始终是最初传入的 attemptedToken,于是它会把刚刚失败的那个 token 再试一遍。

复现:标签页已登录持有 A → 请求返回 401 触发续期 → 续期途中另一标签页写入 B → A 续期成功,但 B 在服务端被拒。实测 refresh 调用三次,参数依次是 A、B、B;A 换回来的有效令牌被丢弃,会话转 guest。

触发条件不宽,失败方向也安全(只会登出,不会串号),但会丢掉一套本来能用的会话。

请你判断:是修这条路径,还是这层「改用更新的 token 重试」本身就该去掉,改成让这种竞争不发生的做法。

@fennoai

fennoai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Done

  • Inspected features/auth-session/index.tsx:122-151
  • Checked shared/api replay semantics
  • Decided on the fix direction
  • Tried focused test validation

结论:保留“跟随更新 token 再续期”的策略,不建议删掉这层机制。这个竞争本身是现实存在的,PR 里也已经在处理跨标签页轮换、挂起恢复和单次重放;直接移除只会让这些场景更容易把会话打回 guest。

这里该修的是 rotateLatestTokens 的失败分支:它在 A -> B 之后,如果 B 也失败,catch 里仍然拿最初的 attemptedToken 去判断“是否还有没试过的新 token”,于是会把刚刚失败的 B 再试一遍。这个位置应当比较“刚失败的那次尝试”对应的 token,或者改成按当前最新 token 迭代一次,避免 A, B, B 这种重复回放。

所以我的判断是:修这条路径,别移除策略。这条策略的目标是正确的,问题只在“失败后追最新 token”的判定变量用错了。

我没在这里提交代码修改;另外本地 npm -C frontend test -- --run ... 受环境影响没法直接跑通,vitest shim 返回了 Permission denied。我已经用代码和现有测试覆盖确认了结论。

View job run

The rotation follow-up compared candidate tokens against the token the
call started with, so a failure while following a newer cross-tab token
fell through to the outer handler, which read that same failed token as
untried and refreshed with it a second time.

Track the tokens tried within a call, and fall back to the pair the
successful refresh already produced instead of discarding it. Following
a rejected newer token no longer signs the user out while a usable
session is in hand.

Refs 1024XEngineer#158
@nighca
nighca merged commit ab6e8d1 into 1024XEngineer:main Aug 7, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: 实现前端认证会话层(Refs #157)

2 participants